From 8ca39c47826a1f488d77b8f7cf6e985ae191819b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 20:32:50 +0000 Subject: [PATCH 01/62] build(deps): bump actions/setup-dotnet from 5.0.1 to 5.1.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.0.1 to 5.1.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.0.1...v5.1.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b89613684..2fd38db60 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.1.0 with: dotnet-version: 8.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 7bb45f26a..b9a14b161 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.1.0 with: dotnet-version: 8.0.x @@ -76,7 +76,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.1.0 with: dotnet-version: 8.0.x @@ -120,7 +120,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.0.1 + uses: actions/setup-dotnet@v5.1.0 with: dotnet-version: 8.0.x From cfd3298cb8d92425877c93d2efc94114813ecb99 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Thu, 5 Feb 2026 09:37:59 -0500 Subject: [PATCH 02/62] refactor: Add config caching to reduce git process calls Context: Git configuration queries currently spawn a new git process for each TryGet(), GetAll(), or Enumerate() call. In scenarios where multiple config values are needed (e.g., credential helper initialization), this results in dozens of git process spawns, each parsing the same config files repeatedly. This impacts performance, especially on Windows where process creation is more expensive. Justification: Rather than caching individual queries, we cache the entire config output from a single 'git config list --show-origin -z' call. This approach provides several benefits: - Single process spawn loads all config values at once - Origin information allows accurate level filtering (system/global/local) - Cache invalidation on write operations keeps data consistent - Thread-safe implementation supports concurrent access We only cache Raw type queries since Bool and Path types require Git's canonicalization logic. Cache is loaded lazily on first access and invalidated on any write operation (Set, Add, Unset, etc.). Implementation: Added ConfigCacheEntry class to store origin, value, and level for each config entry. The ConfigCache class parses the NUL-delimited output from 'git config list --show-origin -z' (format: origin\0key\nvalue\0) and stores entries in a case-insensitive dictionary keyed by config name. Level detection examines the file path in the origin to determine System/Global/Local classification. Fallback to individual git config commands occurs if cache load fails or for typed (Bool/Path) queries. Co-Authored-By: Claude Sonnet 4.5 --- src/shared/Core/GitConfiguration.cs | 301 +++++++++++++++++++++++++++- 1 file changed, 300 insertions(+), 1 deletion(-) diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index 9603b2db5..52b42e541 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -108,24 +108,291 @@ public interface IGitConfiguration void UnsetAll(GitConfigurationLevel level, string name, string valueRegex); } + /// + /// Represents a single configuration entry with its origin and level. + /// + internal class ConfigCacheEntry + { + public string Origin { get; set; } + public string Value { get; set; } + public GitConfigurationLevel Level { get; set; } + + public ConfigCacheEntry(string origin, string value) + { + Origin = origin; + Value = value; + Level = DetermineLevel(origin); + } + + private static GitConfigurationLevel DetermineLevel(string origin) + { + if (string.IsNullOrEmpty(origin)) + return GitConfigurationLevel.Unknown; + + // Origins look like: "file:/path/to/config", "command line:", "standard input:" + if (!origin.StartsWith("file:")) + return GitConfigurationLevel.Unknown; + + string path = origin.Substring(5); // Remove "file:" prefix + + // System config is typically in /etc/gitconfig or $(prefix)/etc/gitconfig + if (path.Contains("/etc/gitconfig") || path.EndsWith("/gitconfig")) + return GitConfigurationLevel.System; + + // Global config is typically in ~/.gitconfig or ~/.config/git/config + if (path.Contains("/.gitconfig") || path.Contains("/.config/git/config")) + return GitConfigurationLevel.Global; + + // Local config is typically in .git/config within a repository + if (path.Contains("/.git/config")) + return GitConfigurationLevel.Local; + + return GitConfigurationLevel.Unknown; + } + } + + /// + /// Cache for Git configuration entries loaded from 'git config list --show-origin -z'. + /// + internal class ConfigCache + { + private Dictionary> _entries; + private readonly object _lock = new object(); + + public bool IsLoaded => _entries != null; + + public void Load(string data, ITrace trace) + { + lock (_lock) + { + var entries = new Dictionary>(GitConfigurationKeyComparer.Instance); + + var origin = new StringBuilder(); + var key = new StringBuilder(); + var value = new StringBuilder(); + + int i = 0; + while (i < data.Length) + { + origin.Clear(); + key.Clear(); + value.Clear(); + + // Read origin (NUL terminated) + while (i < data.Length && data[i] != '\0') + { + origin.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after origin."); + break; + } + + // Skip the NUL terminator + i++; + + // Read key (newline terminated) + while (i < data.Length && data[i] != '\n') + { + key.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected newline terminator (\\n) after key."); + break; + } + + // Skip the newline terminator + i++; + + // Read value (NUL terminated) + while (i < data.Length && data[i] != '\0') + { + value.Append(data[i++]); + } + + if (i >= data.Length) + { + trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after value."); + break; + } + + // Skip the NUL terminator + i++; + + string keyStr = key.ToString(); + var entry = new ConfigCacheEntry(origin.ToString(), value.ToString()); + + if (!entries.ContainsKey(keyStr)) + { + entries[keyStr] = new List(); + } + entries[keyStr].Add(entry); + } + + _entries = entries; + } + } + + public bool TryGet(string name, GitConfigurationLevel level, out string value) + { + lock (_lock) + { + if (_entries == null) + { + value = null; + return false; + } + + if (!_entries.TryGetValue(name, out var entryList)) + { + value = null; + return false; + } + + // Find the first entry matching the level filter + foreach (var entry in entryList) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + value = entry.Value; + return true; + } + } + + value = null; + return false; + } + } + + public IEnumerable GetAll(string name, GitConfigurationLevel level) + { + lock (_lock) + { + if (_entries == null || !_entries.TryGetValue(name, out var entryList)) + { + return Array.Empty(); + } + + var results = new List(); + foreach (var entry in entryList) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + results.Add(entry.Value); + } + } + + return results; + } + } + + public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) + { + lock (_lock) + { + if (_entries == null) + return; + + foreach (var kvp in _entries) + { + foreach (var entry in kvp.Value) + { + if (level == GitConfigurationLevel.All || entry.Level == level) + { + var configEntry = new GitConfigurationEntry(kvp.Key, entry.Value); + if (!cb(configEntry)) + { + return; + } + } + } + } + } + } + + public void Clear() + { + lock (_lock) + { + _entries = null; + } + } + } + public class GitProcessConfiguration : IGitConfiguration { private static readonly GitVersion TypeConfigMinVersion = new GitVersion(2, 18, 0); private readonly ITrace _trace; private readonly GitProcess _git; + private readonly ConfigCache _cache; + private readonly bool _useCache; + + internal GitProcessConfiguration(ITrace trace, GitProcess git) : this(trace, git, useCache: true) + { + } - internal GitProcessConfiguration(ITrace trace, GitProcess git) + internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache) { EnsureArgument.NotNull(trace, nameof(trace)); EnsureArgument.NotNull(git, nameof(git)); _trace = trace; _git = git; + _useCache = useCache; + _cache = useCache ? new ConfigCache() : null; + } + + private void EnsureCacheLoaded() + { + if (!_useCache || _cache.IsLoaded) + return; + + using (ChildProcess git = _git.CreateProcess("config list --show-origin -z")) + { + git.Start(Trace2ProcessClass.Git); + // To avoid deadlocks, always read the output stream first and then wait + string data = git.StandardOutput.ReadToEnd(); + git.WaitForExit(); + + switch (git.ExitCode) + { + case 0: // OK + _cache.Load(data, _trace); + break; + default: + _trace.WriteLine($"Failed to load config cache (exit={git.ExitCode})"); + // Don't throw - fall back to individual commands + break; + } + } + } + + private void InvalidateCache() + { + if (_useCache) + { + _cache.Clear(); + } } public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) { + if (_useCache) + { + EnsureCacheLoaded(); + if (_cache.IsLoaded) + { + _cache.Enumerate(level, cb); + return; + } + } + + // Fall back to original implementation string levelArg = GetLevelFilterArg(level); using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} --list")) { @@ -194,6 +461,17 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, string name, out string value) { + // Use cache for raw types only - typed queries need Git's canonicalization + if (_useCache && type == GitConfigurationType.Raw) + { + EnsureCacheLoaded(); + if (_cache.IsLoaded && _cache.TryGet(name, level, out value)) + { + return true; + } + } + + // Fall back to individual git config command for typed queries or cache miss string levelArg = GetLevelFilterArg(level); string typeArg = GetCanonicalizeTypeArg(type); using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} {typeArg} {QuoteCmdArg(name)}")) @@ -242,6 +520,7 @@ public void Set(GitConfigurationLevel level, string name, string value) switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to set config entry '{name}' to value '{value}' (exit={git.ExitCode}, level={level})"); @@ -263,6 +542,7 @@ public void Add(GitConfigurationLevel level, string name, string value) switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to add config entry '{name}' with value '{value}' (exit={git.ExitCode}, level={level})"); @@ -285,6 +565,7 @@ public void Unset(GitConfigurationLevel level, string name) { case 0: // OK case 5: // Trying to unset a value that does not exist + InvalidateCache(); break; default: _trace.WriteLine($"Failed to unset config entry '{name}' (exit={git.ExitCode}, level={level})"); @@ -295,6 +576,22 @@ public void Unset(GitConfigurationLevel level, string name) public IEnumerable GetAll(GitConfigurationLevel level, GitConfigurationType type, string name) { + // Use cache for raw types only - typed queries need Git's canonicalization + if (_useCache && type == GitConfigurationType.Raw) + { + EnsureCacheLoaded(); + if (_cache.IsLoaded) + { + var cachedValues = _cache.GetAll(name, level); + foreach (var val in cachedValues) + { + yield return val; + } + yield break; + } + } + + // Fall back to individual git config command string levelArg = GetLevelFilterArg(level); string typeArg = GetCanonicalizeTypeArg(type); @@ -392,6 +689,7 @@ public void ReplaceAll(GitConfigurationLevel level, string name, string valueReg switch (git.ExitCode) { case 0: // OK + InvalidateCache(); break; default: _trace.WriteLine($"Failed to replace all multivar '{name}' and value regex '{valueRegex}' with new value '{value}' (exit={git.ExitCode}, level={level})"); @@ -420,6 +718,7 @@ public void UnsetAll(GitConfigurationLevel level, string name, string valueRegex { case 0: // OK case 5: // Trying to unset a value that does not exist + InvalidateCache(); break; default: _trace.WriteLine($"Failed to unset all multivar '{name}' with value regex '{valueRegex}' (exit={git.ExitCode}, level={level})"); From 625d2541d644075ed1c803c7eba09e5ef72741bc Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Thu, 5 Feb 2026 09:39:52 -0500 Subject: [PATCH 03/62] test: Add cache tests and fix precedence bug Context: The caching implementation needed comprehensive tests to verify correct behavior across different scenarios: cache hits, cache invalidation, level filtering, and multivar handling. Tests revealed a critical bug where the cache returned the wrong value when multiple config levels (system/global/local) defined the same key. Justification: Tests follow existing patterns in GitConfigurationTests.cs, creating temporary repositories and verifying cache behavior through the public IGitConfiguration interface rather than testing internal cache classes directly. This ensures we test the actual behavior users will experience. The precedence bug occurred because ConfigCache.TryGet() returned the first matching entry when it should return the last one. Git outputs config values in precedence order (system, global, local), with later values overriding earlier ones. Returning the last match correctly implements Git's precedence rules. Implementation: Added 8 new test methods covering: - Cache loading and retrieval (TryGet, GetAll, Enumerate) - Cache invalidation on write operations (Set, Add, Unset) - Level filtering to isolate local/global/system values - Typed queries that bypass cache for Git's canonicalization Fixed ConfigCache.TryGet() to iterate through all matching entries and return the last one instead of the first, ensuring local config wins over global, which wins over system. All 805 tests pass with the fix applied. Co-Authored-By: Claude Sonnet 4.5 --- .../Core.Tests/GitConfigurationTests.cs | 219 ++++++++++++++++++ src/shared/Core/GitConfiguration.cs | 13 +- 2 files changed, 229 insertions(+), 3 deletions(-) diff --git a/src/shared/Core.Tests/GitConfigurationTests.cs b/src/shared/Core.Tests/GitConfigurationTests.cs index 498651f73..e4ca872de 100644 --- a/src/shared/Core.Tests/GitConfigurationTests.cs +++ b/src/shared/Core.Tests/GitConfigurationTests.cs @@ -436,5 +436,224 @@ public void GitConfiguration_UnsetAll_All_ThrowsException() Assert.Throws(() => config.UnsetAll(GitConfigurationLevel.All, "core.foobar", Constants.RegexPatterns.Any)); } + + [Fact] + public void GitConfiguration_CacheTryGet_ReturnsValueFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local user.name john.doe").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local user.email john@example.com").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // First access loads cache + bool result1 = config.TryGet("user.name", false, out string value1); + Assert.True(result1); + Assert.Equal("john.doe", value1); + + // Second access should use cache + bool result2 = config.TryGet("user.email", false, out string value2); + Assert.True(result2); + Assert.Equal("john@example.com", value2); + } + + [Fact] + public void GitConfiguration_CacheGetAll_ReturnsAllValuesFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value1").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value2").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local --add test.multi value3").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var values = new List(config.GetAll("test.multi")); + + Assert.Equal(3, values.Count); + Assert.Equal("value1", values[0]); + Assert.Equal("value2", values[1]); + Assert.Equal("value3", values[2]); + } + + [Fact] + public void GitConfiguration_CacheEnumerate_EnumeratesFromCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local cache.name test-value").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local cache.enabled true").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + var cacheEntries = new List<(string key, string value)>(); + config.Enumerate(entry => + { + if (entry.Key.StartsWith("cache.")) + { + cacheEntries.Add((entry.Key, entry.Value)); + } + return true; + }); + + Assert.Equal(2, cacheEntries.Count); + Assert.Contains(("cache.name", "test-value"), cacheEntries); + Assert.Contains(("cache.enabled", "true"), cacheEntries); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_SetInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.value initial").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache with initial value + bool result1 = config.TryGet("test.value", false, out string value1); + Assert.True(result1); + Assert.Equal("initial", value1); + + // Set new value (should invalidate cache) + config.Set(GitConfigurationLevel.Local, "test.value", "updated"); + + // Next read should get updated value + bool result2 = config.TryGet("test.value", false, out string value2); + Assert.True(result2); + Assert.Equal("updated", value2); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_AddInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.multi first").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache + var values1 = new List(config.GetAll("test.multi")); + Assert.Single(values1); + Assert.Equal("first", values1[0]); + + // Add new value (should invalidate cache) + config.Add(GitConfigurationLevel.Local, "test.multi", "second"); + + // Next read should include new value + var values2 = new List(config.GetAll("test.multi")); + Assert.Equal(2, values2.Count); + Assert.Equal("first", values2[0]); + Assert.Equal("second", values2[1]); + } + + [Fact] + public void GitConfiguration_CacheInvalidation_UnsetInvalidatesCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.value exists").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Load cache + bool result1 = config.TryGet("test.value", false, out string value1); + Assert.True(result1); + Assert.Equal("exists", value1); + + // Unset value (should invalidate cache) + config.Unset(GitConfigurationLevel.Local, "test.value"); + + // Next read should not find value + bool result2 = config.TryGet("test.value", false, out string value2); + Assert.False(result2); + Assert.Null(value2); + } + + [Fact] + public void GitConfiguration_CacheLevelFilter_ReturnsOnlyLocalValues() + { + string repoPath = CreateRepository(out string workDirPath); + + try + { + ExecGit(repoPath, workDirPath, "config --global test.level global-value").AssertSuccess(); + ExecGit(repoPath, workDirPath, "config --local test.level local-value").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Get local value only + bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Raw, + "test.level", out string value); + Assert.True(result); + Assert.Equal("local-value", value); + } + finally + { + // Cleanup global config + ExecGit(repoPath, workDirPath, "config --global --unset test.level"); + } + } + + [Fact] + public void GitConfiguration_TypedQuery_DoesNotUseCache() + { + string repoPath = CreateRepository(out string workDirPath); + ExecGit(repoPath, workDirPath, "config --local test.path ~/example").AssertSuccess(); + + string gitPath = GetGitPath(); + var trace = new NullTrace(); + var trace2 = new NullTrace2(); + var processManager = new TestProcessManager(); + + var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); + IGitConfiguration config = git.GetConfiguration(); + + // Path type should not use cache (needs Git's canonicalization) + bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Path, + "test.path", out string value); + Assert.True(result); + Assert.NotNull(value); + // Value should be canonicalized path, not raw "~/example" + Assert.NotEqual("~/example", value); + } } } diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index 52b42e541..89cf020ed 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -253,16 +253,23 @@ public bool TryGet(string name, GitConfigurationLevel level, out string value) return false; } - // Find the first entry matching the level filter + // Find the last entry matching the level filter (respects Git's precedence) + // Git config precedence: system < global < local, so last match wins + ConfigCacheEntry lastMatch = null; foreach (var entry in entryList) { if (level == GitConfigurationLevel.All || entry.Level == level) { - value = entry.Value; - return true; + lastMatch = entry; } } + if (lastMatch != null) + { + value = lastMatch.Value; + return true; + } + value = null; return false; } From ae79074c6400d7ea48275ae67fea7e3515cc61da Mon Sep 17 00:00:00 2001 From: "Aria Golliver (she/her)" Date: Tue, 17 Feb 2026 11:17:23 -0800 Subject: [PATCH 04/62] enable installing on azurelinux --- .github/workflows/validate-install-from-source.yml | 3 ++- src/linux/Packaging.Linux/install-from-source.sh | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index d1aea471a..beecc9ae1 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -29,6 +29,7 @@ jobs: - image: registry.suse.com/suse/sle15:15.4.27.11.31 - image: archlinux - image: mcr.microsoft.com/cbl-mariner/base/core:2.0 + - image: mcr.microsoft.com/azurelinux/base/core:3.0 container: ${{matrix.vector.image}} steps: - run: | @@ -36,7 +37,7 @@ jobs: zypper -n install tar gzip elif [[ ${{matrix.vector.image}} == *"centos"* ]]; then dnf install which -y - elif [[ ${{matrix.vector.image}} == *"mariner"* ]]; then + elif [[ ${{matrix.vector.image}} == *"mariner"* || ${{matrix.vector.image}} == *"azurelinux"* ]]; then GNUPGHOME=/root/.gnupg tdnf update -y && GNUPGHOME=/root/.gnupg tdnf install tar -y # needed for `actions/checkout` fi diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index 1a8ede938..d323ac9e1 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -231,7 +231,7 @@ case "$distribution" in ensure_dotnet_installed ;; - mariner) + mariner | azurelinux*) print_unsupported_distro "WARNING" "$distribution" $sudo_cmd tdnf update -y From dde90d4beec7ef6fcd5051dafb344839dbbf112d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:32:39 +0000 Subject: [PATCH 05/62] build(deps): bump lycheeverse/lychee-action from 2.7.0 to 2.8.0 Bumps [lycheeverse/lychee-action](https://github.com/lycheeverse/lychee-action) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/lycheeverse/lychee-action/releases) - [Commits](https://github.com/lycheeverse/lychee-action/compare/a8c4c7cb88f0c7386610c35eb25108e448569cb0...8646ba30535128ac92d33dfc9133794bfdd9b411) --- updated-dependencies: - dependency-name: lycheeverse/lychee-action dependency-version: 2.8.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/lint-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 9fecfd863..26d4f4409 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -35,7 +35,7 @@ jobs: - name: Run link checker # For any troubleshooting, see: # https://github.com/lycheeverse/lychee/blob/master/docs/TROUBLESHOOTING.md - uses: lycheeverse/lychee-action@a8c4c7cb88f0c7386610c35eb25108e448569cb0 + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 with: # user-agent: if a user agent is not specified, some websites (e.g. # GitHub Docs) return HTTP errors which Lychee will interpret as From 02fedccf0d3552ee24dcfc32e675b454207d3e87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:32:41 +0000 Subject: [PATCH 06/62] build(deps): bump actions/upload-artifact from 6 to 7 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/continuous-integration.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 1eee20ccf..c59f623f7 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -56,7 +56,7 @@ jobs: cp out/windows/Installer.Windows/bin/Release/net472/${{ matrix.runtime }}.sym/* artifacts/bin/${{ matrix.runtime }}/ - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.runtime }} path: | @@ -100,7 +100,7 @@ jobs: mv out/linux/Packaging.Linux/Release/tar/*.tar.gz artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.runtime }} path: | @@ -145,7 +145,7 @@ jobs: mv out/osx/Installer.Mac/pkg/Release/gcm*.pkg artifacts/ - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.runtime }} path: | From 29dd825ba57d9ed70ef090375507ad9d9b57eab4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:13:51 +0000 Subject: [PATCH 07/62] Initial plan From 32654f49a3643080c2d3a94e1c1c824c25c23670 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:24:34 +0000 Subject: [PATCH 08/62] Fix pass credential store to support .gpg-id in subdirectories (issue #2263) Co-authored-by: marekzmyslowski <1062877+marekzmyslowski@users.noreply.github.com> --- .../Posix/GnuPassCredentialStoreTests.cs | 56 +++++++++++++++++++ src/shared/Core/CredentialStore.cs | 12 ---- .../Interop/Posix/GpgPassCredentialStore.cs | 34 +++++++---- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs index 7ff80f03d..0f27d8981 100644 --- a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs +++ b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs @@ -86,6 +86,40 @@ public void GnuPassCredentialStore_Remove_NotFound_ReturnsFalse() Assert.False(result); } + [PosixFact] + public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory() + { + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + string storeRoot = InitializePasswordStoreWithGpgIdInSubdirectory(fs, gpg, TestNamespace); + + var collection = new GpgPassCredentialStore(fs, gpg, storeRoot, TestNamespace); + + // Create a service that is guaranteed to be unique + string uniqueGuid = Guid.NewGuid().ToString("N"); + string service = $"https://example.com/{uniqueGuid}"; + const string userName = "john.doe"; + const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + + try + { + // Write + collection.AddOrUpdate(service, userName, password); + + // Read + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, outCredential.Account); + Assert.Equal(password, outCredential.Password); + } + finally + { + // Ensure we clean up after ourselves even in case of 'get' failures + collection.Remove(service, userName); + } + } + private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) { string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); @@ -102,5 +136,27 @@ private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) return storePath; } + + private static string InitializePasswordStoreWithGpgIdInSubdirectory(TestFileSystem fs, TestGpg gpg, string subdirectory) + { + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + string userId = "gcm-test@example.com"; + + // Place .gpg-id only in the namespace subdirectory (not the store root), + // simulating a pass store where the root has no .gpg-id but submodules do. + string subDirPath = Path.Combine(storePath, subdirectory); + string gpgIdPath = Path.Combine(subDirPath, ".gpg-id"); + + // Ensure we have a GPG key for use with testing + gpg.GenerateKeys(userId); + + // Init the password store with .gpg-id only in the subdirectory + fs.Directories.Add(storePath); + fs.Directories.Add(subDirPath); + fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId); + + return storePath; + } } } diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs index 11dc83818..95d26df32 100644 --- a/src/shared/Core/CredentialStore.cs +++ b/src/shared/Core/CredentialStore.cs @@ -291,18 +291,6 @@ private void ValidateGpgPass(out string storeRoot, out string execPath) storeRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".password-store"); } - // Check we have a GPG ID to sign credential files with - string gpgIdFile = Path.Combine(storeRoot, ".gpg-id"); - if (!_context.FileSystem.FileExists(gpgIdFile)) - { - var format = - "Password store has not been initialized at '{0}'; run `pass init ` to initialize the store."; - var message = string.Format(format, storeRoot); - _context.Trace2.WriteError(message); - throw new Exception(message + Environment.NewLine + - $"See {Constants.HelpUrls.GcmCredentialStores} for more information." - ); - } } private void ValidateCredentialCache(out string options) diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs index 6ed56c693..3181f32a1 100644 --- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs +++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs @@ -21,19 +21,33 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot protected override string CredentialFileExtension => ".gpg"; - private string GetGpgId() + private string GetGpgId(string credentialPath) { - string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id"); - if (!FileSystem.FileExists(gpgIdPath)) + // Walk up from the credential's directory to the store root, looking for a .gpg-id file. + // This mimics the behavior of pass, which uses the nearest .gpg-id in the directory hierarchy. + string dir = Path.GetDirectoryName(credentialPath); + while (dir != null) { - throw new Exception($"Cannot find GPG ID in '{gpgIdPath}'; password store has not been initialized"); - } + string gpgIdPath = Path.Combine(dir, ".gpg-id"); + if (FileSystem.FileExists(gpgIdPath)) + { + using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) + { + return reader.ReadLine(); + } + } - using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var reader = new StreamReader(stream)) - { - return reader.ReadLine(); + // Stop after checking the store root + if (FileSystem.IsSamePath(dir, StoreRoot)) + { + break; + } + + dir = Path.GetDirectoryName(dir); } + + throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init ` to initialize the store."); } protected override bool TryDeserializeCredential(string path, out FileCredential credential) @@ -68,7 +82,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential protected override void SerializeCredential(FileCredential credential) { - string gpgId = GetGpgId(); + string gpgId = GetGpgId(credential.FullPath); var sb = new StringBuilder(credential.Password); sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine); From c5128fab3b7db2269e234e4cf070089d48c13692 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:43:06 +0000 Subject: [PATCH 09/62] Remove credentialPath param from GetGpgId, use StoreRoot with EnumerateFiles Co-authored-by: marekzmyslowski <1062877+marekzmyslowski@users.noreply.github.com> --- .../Interop/Posix/GpgPassCredentialStore.cs | 30 ++++++------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs index 3181f32a1..e560ff920 100644 --- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs +++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs @@ -21,30 +21,18 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot protected override string CredentialFileExtension => ".gpg"; - private string GetGpgId(string credentialPath) + private string GetGpgId() { - // Walk up from the credential's directory to the store root, looking for a .gpg-id file. - // This mimics the behavior of pass, which uses the nearest .gpg-id in the directory hierarchy. - string dir = Path.GetDirectoryName(credentialPath); - while (dir != null) + // Search for a .gpg-id file anywhere under the store root. + // This handles configurations where .gpg-id is in a subdirectory + // (e.g., a git submodule) rather than the store root itself. + foreach (string gpgIdPath in FileSystem.EnumerateFiles(StoreRoot, ".gpg-id")) { - string gpgIdPath = Path.Combine(dir, ".gpg-id"); - if (FileSystem.FileExists(gpgIdPath)) + using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) { - using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var reader = new StreamReader(stream)) - { - return reader.ReadLine(); - } + return reader.ReadLine(); } - - // Stop after checking the store root - if (FileSystem.IsSamePath(dir, StoreRoot)) - { - break; - } - - dir = Path.GetDirectoryName(dir); } throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init ` to initialize the store."); @@ -82,7 +70,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential protected override void SerializeCredential(FileCredential credential) { - string gpgId = GetGpgId(credential.FullPath); + string gpgId = GetGpgId(); var sb = new StringBuilder(credential.Password); sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine); From b879c6371ec6e22120c7ecf30a2144c04bf9058a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:44:52 +0000 Subject: [PATCH 10/62] Use StoreRoot instead of credentialPath parameter in GetGpgId Co-authored-by: marekzmyslowski <1062877+marekzmyslowski@users.noreply.github.com> --- .../Interop/Posix/GpgPassCredentialStore.cs | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs index 3181f32a1..9780b4179 100644 --- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs +++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs @@ -21,30 +21,16 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot protected override string CredentialFileExtension => ".gpg"; - private string GetGpgId(string credentialPath) + private string GetGpgId() { - // Walk up from the credential's directory to the store root, looking for a .gpg-id file. - // This mimics the behavior of pass, which uses the nearest .gpg-id in the directory hierarchy. - string dir = Path.GetDirectoryName(credentialPath); - while (dir != null) + string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id"); + if (FileSystem.FileExists(gpgIdPath)) { - string gpgIdPath = Path.Combine(dir, ".gpg-id"); - if (FileSystem.FileExists(gpgIdPath)) + using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) { - using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var reader = new StreamReader(stream)) - { - return reader.ReadLine(); - } + return reader.ReadLine(); } - - // Stop after checking the store root - if (FileSystem.IsSamePath(dir, StoreRoot)) - { - break; - } - - dir = Path.GetDirectoryName(dir); } throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init ` to initialize the store."); @@ -82,7 +68,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential protected override void SerializeCredential(FileCredential credential) { - string gpgId = GetGpgId(credential.FullPath); + string gpgId = GetGpgId(); var sb = new StringBuilder(credential.Password); sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine); From 6d53ee6beb8089496612ccd5cbfa9606f261eeb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:45:48 +0000 Subject: [PATCH 11/62] Use StoreRoot with EnumerateFiles instead of credentialPath parameter in GetGpgId Co-authored-by: marekzmyslowski <1062877+marekzmyslowski@users.noreply.github.com> --- src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs index 9780b4179..e560ff920 100644 --- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs +++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs @@ -23,8 +23,10 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot private string GetGpgId() { - string gpgIdPath = Path.Combine(StoreRoot, ".gpg-id"); - if (FileSystem.FileExists(gpgIdPath)) + // Search for a .gpg-id file anywhere under the store root. + // This handles configurations where .gpg-id is in a subdirectory + // (e.g., a git submodule) rather than the store root itself. + foreach (string gpgIdPath in FileSystem.EnumerateFiles(StoreRoot, ".gpg-id")) { using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var reader = new StreamReader(stream)) From 29f16b367c81c6fe06987754ad8f5fee1603aa51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:05:05 +0000 Subject: [PATCH 12/62] Fix GetGpgId: walk up from credential path to StoreRoot per GNU Pass behaviour Co-authored-by: marekzmyslowski <1062877+marekzmyslowski@users.noreply.github.com> --- .../Posix/GnuPassCredentialStoreTests.cs | 50 +++++++++++++++++++ .../Interop/Posix/GpgPassCredentialStore.cs | 30 +++++++---- 2 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs index 0f27d8981..4bcafb5e7 100644 --- a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs +++ b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs @@ -120,6 +120,56 @@ public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory() } } + [PosixFact] + public void GnuPassCredentialStore_WriteCredential_MultipleGpgIds_UsesNearestGpgId() + { + // Verify that when two subdirectories each have their own .gpg-id, encrypting a credential + // under one subdirectory uses that subdirectory's GPG identity, not the other one. + var fs = new TestFileSystem(); + var gpg = new TestGpg(fs); + + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + + const string personalUserId = "personal@example.com"; + const string workUserId = "work@example.com"; + + // Only register the personal key; if the wrong (work) key is picked, EncryptFile will throw. + gpg.GenerateKeys(personalUserId); + + string personalSubDir = Path.Combine(storePath, "personal"); + string workSubDir = Path.Combine(storePath, "work"); + + fs.Directories.Add(storePath); + fs.Directories.Add(personalSubDir); + fs.Directories.Add(workSubDir); + fs.Files[Path.Combine(personalSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(personalUserId); + fs.Files[Path.Combine(workSubDir, ".gpg-id")] = Encoding.UTF8.GetBytes(workUserId); + + // Use "personal" namespace so credentials are stored under storePath/personal/... + var collection = new GpgPassCredentialStore(fs, gpg, storePath, "personal"); + + string service = $"https://example.com/{Guid.NewGuid():N}"; + const string userName = "john.doe"; + const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + + try + { + // Write - should pick personal/.gpg-id (personalUserId), not work/.gpg-id (workUserId) + collection.AddOrUpdate(service, userName, password); + + ICredential outCredential = collection.Get(service, userName); + + Assert.NotNull(outCredential); + Assert.Equal(userName, outCredential.Account); + Assert.Equal(password, outCredential.Password); + } + finally + { + collection.Remove(service, userName); + } + } + private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) { string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); diff --git a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs index e560ff920..debc9c815 100644 --- a/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs +++ b/src/shared/Core/Interop/Posix/GpgPassCredentialStore.cs @@ -21,18 +21,30 @@ public GpgPassCredentialStore(IFileSystem fileSystem, IGpg gpg, string storeRoot protected override string CredentialFileExtension => ".gpg"; - private string GetGpgId() + private string GetGpgId(string credentialFullPath) { - // Search for a .gpg-id file anywhere under the store root. - // This handles configurations where .gpg-id is in a subdirectory - // (e.g., a git submodule) rather than the store root itself. - foreach (string gpgIdPath in FileSystem.EnumerateFiles(StoreRoot, ".gpg-id")) + // Walk up from the credential's directory to the store root, looking for a .gpg-id file. + // This mimics the behaviour of GNU Pass, which uses the nearest .gpg-id in the directory hierarchy. + string dir = Path.GetDirectoryName(credentialFullPath); + while (dir != null) { - using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var reader = new StreamReader(stream)) + string gpgIdPath = Path.Combine(dir, ".gpg-id"); + if (FileSystem.FileExists(gpgIdPath)) { - return reader.ReadLine(); + using (var stream = FileSystem.OpenFileStream(gpgIdPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var reader = new StreamReader(stream)) + { + return reader.ReadLine(); + } } + + // Stop after checking the store root + if (FileSystem.IsSamePath(dir, StoreRoot)) + { + break; + } + + dir = Path.GetDirectoryName(dir); } throw new Exception($"Cannot find GPG ID in password store at '{StoreRoot}'; run `pass init ` to initialize the store."); @@ -70,7 +82,7 @@ protected override bool TryDeserializeCredential(string path, out FileCredential protected override void SerializeCredential(FileCredential credential) { - string gpgId = GetGpgId(); + string gpgId = GetGpgId(credential.FullPath); var sb = new StringBuilder(credential.Password); sb.AppendFormat("{1}service={0}{1}", credential.Service, Environment.NewLine); From 50bae928b52b65d0db98f2601337bc568cbe573e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:18:46 +0000 Subject: [PATCH 13/62] Remove fake credentials from test file: use Guid.NewGuid() for test passwords Co-authored-by: marekzmyslowski <1062877+marekzmyslowski@users.noreply.github.com> --- .../Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs index 4bcafb5e7..220ba1a9f 100644 --- a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs +++ b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs @@ -99,7 +99,7 @@ public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory() string uniqueGuid = Guid.NewGuid().ToString("N"); string service = $"https://example.com/{uniqueGuid}"; const string userName = "john.doe"; - const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + string password = Guid.NewGuid().ToString("N"); try { @@ -151,7 +151,7 @@ public void GnuPassCredentialStore_WriteCredential_MultipleGpgIds_UsesNearestGpg string service = $"https://example.com/{Guid.NewGuid():N}"; const string userName = "john.doe"; - const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + string password = Guid.NewGuid().ToString("N"); try { From eeccd46373fb96765c5d66f3c4e93f150fd71876 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:32:45 +0000 Subject: [PATCH 14/62] build(deps): bump actions/setup-dotnet from 5.1.0 to 5.2.0 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.1.0 to 5.2.0. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5.1.0...v5.2.0) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: 5.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2fd38db60..34f37972a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index c59f623f7..0f7967da3 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x @@ -76,7 +76,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x @@ -120,7 +120,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v5.1.0 + uses: actions/setup-dotnet@v5.2.0 with: dotnet-version: 8.0.x From 78842ef9ffd328c0a7196f5d157543a94f493006 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 10 Mar 2026 12:12:02 +0000 Subject: [PATCH 15/62] Update Windows registry section in enterprise-config.md Clarified registry access for 32-bit GCM on 64-bit Windows. --- docs/enterprise-config.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/enterprise-config.md b/docs/enterprise-config.md index d0cf30aba..0e8591f60 100644 --- a/docs/enterprise-config.md +++ b/docs/enterprise-config.md @@ -30,22 +30,10 @@ that can always be overridden by the user in the usual ways. Default setting values come from the Windows Registry, specifically the following keys: -### 32-bit Windows - ```text HKEY_LOCAL_MACHINE\SOFTWARE\GitCredentialManager\Configuration ``` -### 64-bit Windows - -```text -HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GitCredentialManager\Configuration -``` - -> GCM is a 32-bit executable on Windows. When running on a 64-bit -installation of Windows registry access is transparently redirected to the -`WOW6432Node` node. - By using the Windows Registry, system administrators can use Group Policy to easily set defaults for GCM's settings. @@ -55,6 +43,16 @@ those of the [Git configuration][config] settings. The type of each registry key can be either `REG_SZ` (string) or `REG_DWORD` (integer). +### 32-bit / x86 + +When running the 32-bit (x86) version of GCM on a 64-bit (x64 or ARM64) +installation of Windows, the registry access is transparently redirected to +the `WOW6432Node` node. + +```text +HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GitCredentialManager\Configuration +``` + ## macOS Default settings values come from macOS's preferences system. Configuration From 4f37e544c1969c5d7ffd1aa98726c3879abfeddc Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sun, 1 Mar 2026 12:56:48 -0500 Subject: [PATCH 16/62] feat: Cache config entries by type for typed queries Context: The config cache only stored raw (untyped) values, so Bool and Path queries always fell back to spawning individual git processes. Since Git's --type flag canonicalizes values (e.g., expanding ~/... for paths, normalizing yes/on/1 to true for bools), serving these from the raw cache would return incorrect values. Justification: Instead of bypassing the cache for typed queries, we maintain a separate cache per GitConfigurationType. Each cache is loaded with the appropriate --type flag passed to 'git config list', so Git performs canonicalization during the bulk load. This preserves the correctness guarantee while extending the performance benefit to all query types. The cache result is now authoritative when loaded: if a key is not found in the cache, we return 'not found' directly rather than falling back to an individual git process call. This avoids a redundant process spawn when the key genuinely doesn't exist. Implementation: Changed _cache from a single ConfigCache to a Dictionary keyed by GitConfigurationType. EnsureCacheLoaded() now accepts a type parameter and passes --no-type, --type=bool, or --type=path to the git config list command. InvalidateCache() clears all type-specific caches on any write operation. Renamed TypedQuery_DoesNotUseCache test to TypedQuery_CanonicalizesValues since typed queries now use their own type-specific cache rather than bypassing the cache entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Core.Tests/GitConfigurationTests.cs | 5 +- src/shared/Core/GitConfiguration.cs | 81 ++++++++++++++----- 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/src/shared/Core.Tests/GitConfigurationTests.cs b/src/shared/Core.Tests/GitConfigurationTests.cs index e4ca872de..5005cf431 100644 --- a/src/shared/Core.Tests/GitConfigurationTests.cs +++ b/src/shared/Core.Tests/GitConfigurationTests.cs @@ -634,7 +634,7 @@ public void GitConfiguration_CacheLevelFilter_ReturnsOnlyLocalValues() } [Fact] - public void GitConfiguration_TypedQuery_DoesNotUseCache() + public void GitConfiguration_TypedQuery_CanonicalizesValues() { string repoPath = CreateRepository(out string workDirPath); ExecGit(repoPath, workDirPath, "config --local test.path ~/example").AssertSuccess(); @@ -647,7 +647,8 @@ public void GitConfiguration_TypedQuery_DoesNotUseCache() var git = new GitProcess(trace, trace2, processManager, gitPath, repoPath); IGitConfiguration config = git.GetConfiguration(); - // Path type should not use cache (needs Git's canonicalization) + // Path type queries use a separate cache loaded with --type=path, + // so Git canonicalizes the values during cache load. bool result = config.TryGet(GitConfigurationLevel.Local, GitConfigurationType.Path, "test.path", out string value); Assert.True(result); diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index 89cf020ed..3e7878f89 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Text; namespace GitCredentialManager @@ -336,7 +337,7 @@ public class GitProcessConfiguration : IGitConfiguration private readonly ITrace _trace; private readonly GitProcess _git; - private readonly ConfigCache _cache; + private readonly Dictionary _cache; private readonly bool _useCache; internal GitProcessConfiguration(ITrace trace, GitProcess git) : this(trace, git, useCache: true) @@ -351,15 +352,44 @@ internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache) _trace = trace; _git = git; _useCache = useCache; - _cache = useCache ? new ConfigCache() : null; + _cache = useCache ? new Dictionary() : null; } - private void EnsureCacheLoaded() + private void EnsureCacheLoaded(GitConfigurationType type) { - if (!_useCache || _cache.IsLoaded) + ConfigCache cache; + if (!_useCache || (_cache.TryGetValue(type, out cache) && cache.IsLoaded)) + { + return; + } + + if (cache == null) + { + cache = new ConfigCache(); + _cache[type] = cache; + } + + string typeArg; + + switch (type) + { + case GitConfigurationType.Raw: + typeArg = "--no-type"; + break; + + case GitConfigurationType.Path: + typeArg = "--type=path"; + break; + + case GitConfigurationType.Bool: + typeArg = "--type=bool"; + break; + + default: return; + } - using (ChildProcess git = _git.CreateProcess("config list --show-origin -z")) + using (ChildProcess git = _git.CreateProcess($"config list --show-origin -z {typeArg}")) { git.Start(Trace2ProcessClass.Git); // To avoid deadlocks, always read the output stream first and then wait @@ -369,7 +399,7 @@ private void EnsureCacheLoaded() switch (git.ExitCode) { case 0: // OK - _cache.Load(data, _trace); + cache.Load(data, _trace); break; default: _trace.WriteLine($"Failed to load config cache (exit={git.ExitCode})"); @@ -383,7 +413,10 @@ private void InvalidateCache() { if (_useCache) { - _cache.Clear(); + foreach (ConfigCache cache in _cache.Values) + { + cache.Clear(); + } } } @@ -391,10 +424,13 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa { if (_useCache) { - EnsureCacheLoaded(); - if (_cache.IsLoaded) + EnsureCacheLoaded(GitConfigurationType.Raw); + + ConfigCache cache = _cache[GitConfigurationType.Raw]; + + if (cache.IsLoaded) { - _cache.Enumerate(level, cb); + cache.Enumerate(level, cb); return; } } @@ -468,17 +504,19 @@ public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCa public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, string name, out string value) { - // Use cache for raw types only - typed queries need Git's canonicalization - if (_useCache && type == GitConfigurationType.Raw) + if (_useCache) { - EnsureCacheLoaded(); - if (_cache.IsLoaded && _cache.TryGet(name, level, out value)) + EnsureCacheLoaded(type); + + ConfigCache cache = _cache[type]; + if (cache.IsLoaded) { - return true; + // Cache is loaded, use it for the result (whether found or not) + return cache.TryGet(name, level, out value); } } - // Fall back to individual git config command for typed queries or cache miss + // Fall back to individual git config command if cache not available string levelArg = GetLevelFilterArg(level); string typeArg = GetCanonicalizeTypeArg(type); using (ChildProcess git = _git.CreateProcess($"config --null {levelArg} {typeArg} {QuoteCmdArg(name)}")) @@ -583,13 +621,14 @@ public void Unset(GitConfigurationLevel level, string name) public IEnumerable GetAll(GitConfigurationLevel level, GitConfigurationType type, string name) { - // Use cache for raw types only - typed queries need Git's canonicalization - if (_useCache && type == GitConfigurationType.Raw) + if (_useCache) { - EnsureCacheLoaded(); - if (_cache.IsLoaded) + EnsureCacheLoaded(type); + + ConfigCache cache = _cache[type]; + if (cache.IsLoaded) { - var cachedValues = _cache.GetAll(name, level); + var cachedValues = cache.GetAll(name, level); foreach (var val in cachedValues) { yield return val; From 3125246e0210cc3c88498f03ea00455bdc5cedf4 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sun, 1 Mar 2026 12:58:45 -0500 Subject: [PATCH 17/62] feat: Gate config cache on Git 2.54.0+ version check Context: The config cache uses 'git config list --type=' to load type-specific caches (raw, bool, path). The --type flag for the 'list' subcommand requires a fix queued for the Git 2.54.0 release. On Git 2.53 and earlier, the command succeeds but silently ignores the --type parameter, returning raw values instead of canonicalized ones. This means bool caches would contain 'yes'/'on' instead of 'true', and path caches would contain unexpanded '~/...' instead of absolute paths. Justification: Because the command exits 0 on older Git, the cache appears to load successfully and the fallback paths never trigger. This makes the bug silent and data-dependent: lookups work for values that happen to already be in canonical form but return wrong results for others. A version gate is the only reliable way to avoid this. The check is in the constructor body rather than the constructor chain so we can log a trace message when caching is disabled. The explicit useCache parameter is preserved for tests that need to control caching behavior independently of version. Implementation: Added ConfigListTypeMinVersion constant (2.54.0) and a version comparison in the GitProcessConfiguration constructor. When useCache is requested but git.Version is below the minimum, the constructor overrides useCache to false and emits a trace line. All existing fallback paths continue to work unchanged for users on older Git, who will benefit from the cache automatically once they upgrade. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/shared/Core/GitConfiguration.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index 3e7878f89..725e30f67 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -334,6 +334,7 @@ public void Clear() public class GitProcessConfiguration : IGitConfiguration { private static readonly GitVersion TypeConfigMinVersion = new GitVersion(2, 18, 0); + private static readonly GitVersion ConfigListTypeMinVersion = new GitVersion(2, 54, 0); private readonly ITrace _trace; private readonly GitProcess _git; @@ -351,6 +352,14 @@ internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache) _trace = trace; _git = git; + + // 'git config list --type=' requires Git 2.54.0+ + if (useCache && git.Version < ConfigListTypeMinVersion) + { + trace.WriteLine($"Git version {git.Version} is below {ConfigListTypeMinVersion}; config cache disabled"); + useCache = false; + } + _useCache = useCache; _cache = useCache ? new Dictionary() : null; } From 271d13eccbb73ff510ac52888ccb27419ef0af60 Mon Sep 17 00:00:00 2001 From: James Le Cuirot Date: Wed, 11 Mar 2026 12:02:27 +0000 Subject: [PATCH 18/62] linuxsettings: Fix CS0121 string.Split call ambiguity with .NET 8.0 The error in full: error CS0121: The call is ambiguous between the following methods or properties: 'string.Split(char[]?, StringSplitOptions)' and 'string.Split(string?, StringSplitOptions)' [src/shared/Core/Core.csproj::TargetFramework=net8.0] Signed-off-by: James Le Cuirot --- src/shared/Core/Interop/Linux/LinuxConfigParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Interop/Linux/LinuxConfigParser.cs b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs index 1caa918fc..b9b759977 100644 --- a/src/shared/Core/Interop/Linux/LinuxConfigParser.cs +++ b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs @@ -31,7 +31,7 @@ public IDictionary Parse(string content) { var result = new Dictionary(GitConfigurationKeyComparer.Instance); - IEnumerable lines = content.Split(['\n'], StringSplitOptions.RemoveEmptyEntries); + IEnumerable lines = content.Split(new char[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach (string line in lines) { From b6415774ced93c4f28aa9eb477faf3cbebb1a24f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Mar 2026 14:31:14 +0000 Subject: [PATCH 19/62] Inline InitializePasswordStoreWithGpgIdInSubdirectory into its only test Co-authored-by: mjcheetham <5658207+mjcheetham@users.noreply.github.com> --- .../Posix/GnuPassCredentialStoreTests.cs | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs index 220ba1a9f..eb87e8c57 100644 --- a/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs +++ b/src/shared/Core.Tests/Interop/Posix/GnuPassCredentialStoreTests.cs @@ -91,13 +91,25 @@ public void GnuPassCredentialStore_ReadWriteDelete_GpgIdInSubdirectory() { var fs = new TestFileSystem(); var gpg = new TestGpg(fs); - string storeRoot = InitializePasswordStoreWithGpgIdInSubdirectory(fs, gpg, TestNamespace); - var collection = new GpgPassCredentialStore(fs, gpg, storeRoot, TestNamespace); + string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + string storePath = Path.Combine(homePath, ".password-store"); + const string userId = "gcm-test@example.com"; - // Create a service that is guaranteed to be unique - string uniqueGuid = Guid.NewGuid().ToString("N"); - string service = $"https://example.com/{uniqueGuid}"; + // Place .gpg-id only in the namespace subdirectory (not the store root), + // simulating a pass store where the root has no .gpg-id but submodules do. + string subDirPath = Path.Combine(storePath, TestNamespace); + string gpgIdPath = Path.Combine(subDirPath, ".gpg-id"); + + gpg.GenerateKeys(userId); + + fs.Directories.Add(storePath); + fs.Directories.Add(subDirPath); + fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId); + + var collection = new GpgPassCredentialStore(fs, gpg, storePath, TestNamespace); + + string service = $"https://example.com/{Guid.NewGuid():N}"; const string userName = "john.doe"; string password = Guid.NewGuid().ToString("N"); @@ -186,27 +198,5 @@ private static string InitializePasswordStore(TestFileSystem fs, TestGpg gpg) return storePath; } - - private static string InitializePasswordStoreWithGpgIdInSubdirectory(TestFileSystem fs, TestGpg gpg, string subdirectory) - { - string homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - string storePath = Path.Combine(homePath, ".password-store"); - string userId = "gcm-test@example.com"; - - // Place .gpg-id only in the namespace subdirectory (not the store root), - // simulating a pass store where the root has no .gpg-id but submodules do. - string subDirPath = Path.Combine(storePath, subdirectory); - string gpgIdPath = Path.Combine(subDirPath, ".gpg-id"); - - // Ensure we have a GPG key for use with testing - gpg.GenerateKeys(userId); - - // Init the password store with .gpg-id only in the subdirectory - fs.Directories.Add(storePath); - fs.Directories.Add(subDirPath); - fs.Files[gpgIdPath] = Encoding.UTF8.GetBytes(userId); - - return storePath; - } } } From efec51379e09d15dafc846e4c8bc78227ae9ac8d Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Tue, 17 Mar 2026 12:56:43 -0400 Subject: [PATCH 20/62] config: use --show-scope instead of --show-origin in cache Context: The config cache path already gates on Git 2.54.0+ (or microsoft/git 2.53.0.vfs.0.1+) to use 'git config list --type='. These versions also support --show-scope, which directly reports the scope (system, global, local) as a simple string rather than a file path. Justification: The --show-origin approach required heuristic path matching to guess the scope from origin paths like 'file:/etc/gitconfig' or 'file:~/.gitconfig'. This was fragile: it could fail on non-standard install prefixes or platform-specific paths (e.g., Windows). Since --show-scope is available at the same Git versions we already require, we can use it for reliable scope detection with no guesswork. Implementation: - Replace --show-origin with --show-scope in the git config list call - Replace DetermineLevel() path heuristic with ParseScope() that does a direct string match on 'system', 'global', 'local' - Remove the now-unused Origin property from ConfigCacheEntry - Rename origin variables to scope throughout ConfigCache.Load() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/shared/Core/GitConfiguration.cs | 57 +++++++++++------------------ 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index 725e30f67..f8dbeb63e 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -114,46 +114,33 @@ public interface IGitConfiguration /// internal class ConfigCacheEntry { - public string Origin { get; set; } public string Value { get; set; } public GitConfigurationLevel Level { get; set; } - public ConfigCacheEntry(string origin, string value) + public ConfigCacheEntry(string scope, string value) { - Origin = origin; Value = value; - Level = DetermineLevel(origin); + Level = ParseScope(scope); } - private static GitConfigurationLevel DetermineLevel(string origin) + private static GitConfigurationLevel ParseScope(string scope) { - if (string.IsNullOrEmpty(origin)) - return GitConfigurationLevel.Unknown; - - // Origins look like: "file:/path/to/config", "command line:", "standard input:" - if (!origin.StartsWith("file:")) - return GitConfigurationLevel.Unknown; - - string path = origin.Substring(5); // Remove "file:" prefix - - // System config is typically in /etc/gitconfig or $(prefix)/etc/gitconfig - if (path.Contains("/etc/gitconfig") || path.EndsWith("/gitconfig")) - return GitConfigurationLevel.System; - - // Global config is typically in ~/.gitconfig or ~/.config/git/config - if (path.Contains("/.gitconfig") || path.Contains("/.config/git/config")) - return GitConfigurationLevel.Global; - - // Local config is typically in .git/config within a repository - if (path.Contains("/.git/config")) - return GitConfigurationLevel.Local; - - return GitConfigurationLevel.Unknown; + switch (scope) + { + case "system": + return GitConfigurationLevel.System; + case "global": + return GitConfigurationLevel.Global; + case "local": + return GitConfigurationLevel.Local; + default: + return GitConfigurationLevel.Unknown; + } } } /// - /// Cache for Git configuration entries loaded from 'git config list --show-origin -z'. + /// Cache for Git configuration entries loaded from 'git config list --show-scope -z'. /// internal class ConfigCache { @@ -168,26 +155,26 @@ public void Load(string data, ITrace trace) { var entries = new Dictionary>(GitConfigurationKeyComparer.Instance); - var origin = new StringBuilder(); + var scope = new StringBuilder(); var key = new StringBuilder(); var value = new StringBuilder(); int i = 0; while (i < data.Length) { - origin.Clear(); + scope.Clear(); key.Clear(); value.Clear(); - // Read origin (NUL terminated) + // Read scope (NUL terminated) while (i < data.Length && data[i] != '\0') { - origin.Append(data[i++]); + scope.Append(data[i++]); } if (i >= data.Length) { - trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after origin."); + trace.WriteLine("Invalid Git configuration output. Expected null terminator (\\0) after scope."); break; } @@ -225,7 +212,7 @@ public void Load(string data, ITrace trace) i++; string keyStr = key.ToString(); - var entry = new ConfigCacheEntry(origin.ToString(), value.ToString()); + var entry = new ConfigCacheEntry(scope.ToString(), value.ToString()); if (!entries.ContainsKey(keyStr)) { @@ -398,7 +385,7 @@ private void EnsureCacheLoaded(GitConfigurationType type) return; } - using (ChildProcess git = _git.CreateProcess($"config list --show-origin -z {typeArg}")) + using (ChildProcess git = _git.CreateProcess($"config list --show-scope -z {typeArg}")) { git.Start(Trace2ProcessClass.Git); // To avoid deadlocks, always read the output stream first and then wait From 2d2658e31dc91277521bb23fb37faeae787ecc73 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Tue, 17 Mar 2026 12:58:47 -0400 Subject: [PATCH 21/62] GitConfiguration: 'worktree' and 'command' count as local --- src/shared/Core/GitConfiguration.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index f8dbeb63e..e5eb08d6d 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -132,6 +132,8 @@ private static GitConfigurationLevel ParseScope(string scope) case "global": return GitConfigurationLevel.Global; case "local": + case "worktree": + case "command": return GitConfigurationLevel.Local; default: return GitConfigurationLevel.Unknown; From a34a5ce2e3db7a309a346091bb964e93beb64f60 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Sun, 1 Mar 2026 13:13:48 -0500 Subject: [PATCH 22/62] feat: Allow config cache for microsoft/git 2.53.0.vfs.0.1+ Context: The config cache version gate requires Git 2.54.0+ because that is when upstream Git will include the fix for 'git config list --type='. However, the microsoft/git fork may fast-track this fix into a 2.53.0.vfs.0.1 release, allowing users of Git for Windows (VFS-enabled builds) to benefit from the cache sooner. Justification: The microsoft/git fork uses version strings like '2.53.0.vfs.0.1' where the '.vfs.' marker distinguishes it from upstream Git. GitVersion parsing stops at the non-integer 'vfs' component, so a simple numeric comparison would treat this as 2.53.0 and disable caching. We need an additional check that recognizes VFS builds and compares their suffix version. The base version (2.53.0) and VFS suffix (0.1) are checked separately, allowing any future 2.53+ VFS build with the fix to also benefit. Upstream 2.54.0+ continues to pass the existing numeric check without hitting the VFS path. Implementation: Added SupportsConfigListType() helper that first checks the upstream minimum (2.54.0), then looks for '.vfs.' in the original version string. For VFS builds, it parses the base version and VFS suffix independently and checks both against their respective minimums (2.53.0 and 0.1). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/shared/Core/GitConfiguration.cs | 34 ++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/shared/Core/GitConfiguration.cs b/src/shared/Core/GitConfiguration.cs index e5eb08d6d..83a10d591 100644 --- a/src/shared/Core/GitConfiguration.cs +++ b/src/shared/Core/GitConfiguration.cs @@ -324,6 +324,8 @@ public class GitProcessConfiguration : IGitConfiguration { private static readonly GitVersion TypeConfigMinVersion = new GitVersion(2, 18, 0); private static readonly GitVersion ConfigListTypeMinVersion = new GitVersion(2, 54, 0); + private static readonly GitVersion ConfigListTypeMinVfsBase = new GitVersion(2, 53, 0); + private static readonly GitVersion ConfigListTypeMinVfsSuffix = new GitVersion(0, 1); private readonly ITrace _trace; private readonly GitProcess _git; @@ -342,10 +344,11 @@ internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache) _trace = trace; _git = git; - // 'git config list --type=' requires Git 2.54.0+ - if (useCache && git.Version < ConfigListTypeMinVersion) + // 'git config list --type=' requires Git 2.54.0+, + // or microsoft/git fork 2.53.0.vfs.0.1+ + if (useCache && !SupportsConfigListType(git)) { - trace.WriteLine($"Git version {git.Version} is below {ConfigListTypeMinVersion}; config cache disabled"); + trace.WriteLine($"Git version {git.Version.OriginalString} does not support 'git config list --type'; config cache disabled"); useCache = false; } @@ -353,6 +356,31 @@ internal GitProcessConfiguration(ITrace trace, GitProcess git, bool useCache) _cache = useCache ? new Dictionary() : null; } + private static bool SupportsConfigListType(GitProcess git) + { + if (git.Version >= ConfigListTypeMinVersion) + return true; + + // The microsoft/git fork fast-tracked the fix into 2.53.0.vfs.0.1. + // Version strings like "2.53.0.vfs.0.1" parse as [2,53,0] because + // GitVersion stops at the non-integer "vfs" component, so we check + // the original string for the ".vfs." marker and parse the suffix. + string versionStr = git.Version.OriginalString; + if (versionStr != null) + { + int vfsIdx = versionStr.IndexOf(".vfs."); + if (vfsIdx >= 0) + { + var baseVersion = new GitVersion(versionStr.Substring(0, vfsIdx)); + var vfsSuffix = new GitVersion(versionStr.Substring(vfsIdx + 5)); + return baseVersion >= ConfigListTypeMinVfsBase + && vfsSuffix >= ConfigListTypeMinVfsSuffix; + } + } + + return false; + } + private void EnsureCacheLoaded(GitConfigurationType type) { ConfigCache cache; From 944726b23ae377fe015830be87044b04a073f8fa Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Fri, 20 Mar 2026 13:47:25 -0400 Subject: [PATCH 23/62] test: Add write-call tracking to TestGitConfiguration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: TestGitConfiguration.Set and Unset directly manipulate in-memory dictionaries. Tests can assert final dictionary state easily, but have no way to detect whether redundant writes occurred — a write that sets a key to its already-current value, or unsets a key that is already absent. Justification: Simple integer counters on TestGitConfiguration give tests a lightweight, zero-noise way to detect whether config operations fired at all, without introducing a mocking framework or separate spy class. Counters are reset on construction so they reflect only the writes within a single test. Implementation: Added SetCallCount and UnsetCallCount as auto-incremented public properties. Each increments at the top of the corresponding method, before any dictionary logic. Co-Authored-By: Claude Sonnet 4.6 --- .../TestInfrastructure/Objects/TestGitConfiguration.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs index 517a8c7b8..6a887a97c 100644 --- a/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs +++ b/src/shared/TestInfrastructure/Objects/TestGitConfiguration.cs @@ -16,6 +16,9 @@ public class TestGitConfiguration : IGitConfiguration public IDictionary> Local { get; set; } = new Dictionary>(GitConfigurationKeyComparer.Instance); + public int SetCallCount { get; private set; } + public int UnsetCallCount { get; private set; } + #region IGitConfiguration public void Enumerate(GitConfigurationLevel level, GitConfigurationEnumerationCallback cb) @@ -68,6 +71,7 @@ public bool TryGet(GitConfigurationLevel level, GitConfigurationType type, strin public void Set(GitConfigurationLevel level, string name, string value) { + SetCallCount++; IDictionary> dict = GetDictionary(level); if (!dict.TryGetValue(name, out var values)) @@ -107,6 +111,7 @@ public void Add(GitConfigurationLevel level, string name, string value) public void Unset(GitConfigurationLevel level, string name) { + UnsetCallCount++; IDictionary> dict = GetDictionary(level); // Simulate git From 2226b75fdc5b85464cb7105d44ae06c59dd8b3c7 Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Fri, 20 Mar 2026 13:47:50 -0400 Subject: [PATCH 24/62] fix: Skip redundant git config writes in SignIn when already correct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: On every `git fetch`, git calls `credential approve` after successful authentication, which invokes GCM's StoreCredentialAsync. For OAuth flows against Azure Repos, this calls SignIn(orgName, userName) to record the user-to-org binding in git config. In the common single-user steady state — global binding already set to the authenticated user, no local override — SignIn was still issuing `git config --global credential.azrepos:org/.username` and `git config --local --unset credential.azrepos:org/.username` on every invocation, even though neither write was needed. Justification: The else branch of SignIn that handles "global absent or matches" called Bind(global) + Unbind(local) unconditionally, without first checking whether the state was already correct. The fix adds minimal guards: skip Bind(global) if global is already set to the signing-in user, skip Unbind(local) if local is already absent. This eliminates the round-trips to git in the steady state without changing the outcome in any other case. The same pattern applies to the B|A case (different user holds the global binding, local is already set to the signing-in user): that branch also skipped the write check, so it is guarded here too. Implementation: Modified SignIn to skip writes when already in the desired state: A | - -> A | - no writes (previously Set(global)+Unset(local)) A | A -> A | - only Unset(local), Set(global) skipped A | B -> A | - only Unset(local), Set(global) skipped B | A -> B | A no writes (already correct, was also correct) Added four tests to AzureReposBindingManagerTests using the new SetCallCount/UnsetCallCount counters to assert the precise number of config writes for each case. Co-Authored-By: Claude Sonnet 4.6 --- .../AzureReposBindingManagerTests.cs | 87 +++++++++++++++++++ .../AzureReposBindingManager.cs | 18 +++- 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs index 2c506ae62..ec60d8f70 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposBindingManagerTests.cs @@ -628,6 +628,93 @@ public void AzureReposBindingManager_SignIn_OtherGlobalOtherLocal_BindsLocal() Assert.Equal(user2, actualGlobalUser); } + // Idempotency: SignIn when state is already correct should not write to git config + + [Fact] + public void AzureReposBindingManager_SignIn_SameGlobalNoLocal_NoConfigWrites() + { + // Steady-state: global already bound to signing-in user, no local override. + // This is the common case on every 'git fetch' after the first sign-in. + const string orgName = "org"; + const string user1 = "user1"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user1}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(0, git.Configuration.UnsetCallCount); + } + + [Fact] + public void AzureReposBindingManager_SignIn_OtherGlobalSameLocal_NoConfigWrites() + { + // Steady-state: a different user holds the global binding, and local is already + // bound to the signing-in user. No change needed. + const string orgName = "org"; + const string user1 = "user1"; + const string user2 = "user2"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user2}; + git.Configuration.Local[CreateKey(orgName)] = new[] {user1}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(0, git.Configuration.UnsetCallCount); + } + + [Fact] + public void AzureReposBindingManager_SignIn_SameGlobalSameLocal_OnlyUnbindsLocal() + { + // Global already matches, local redundantly mirrors it. + // Only the local unset is needed; re-writing the global value is wasteful. + const string orgName = "org"; + const string user1 = "user1"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user1}; + git.Configuration.Local[CreateKey(orgName)] = new[] {user1}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(1, git.Configuration.UnsetCallCount); + } + + [Fact] + public void AzureReposBindingManager_SignIn_SameGlobalOtherLocal_OnlyUnbindsLocal() + { + // Global already matches, local has a different user that needs removing. + // Only the local unset is needed; re-writing the global value is wasteful. + const string orgName = "org"; + const string user1 = "user1"; + const string user2 = "user2"; + + var git = new TestGit(); + var trace = new NullTrace(); + var manager = new AzureReposBindingManager(trace, git); + + git.Configuration.Global[CreateKey(orgName)] = new[] {user1}; + git.Configuration.Local[CreateKey(orgName)] = new[] {user2}; + + manager.SignIn(orgName, user1); + + Assert.Equal(0, git.Configuration.SetCallCount); + Assert.Equal(1, git.Configuration.UnsetCallCount); + } + #endregion #region SignOut diff --git a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs index 7e26590bd..65006a8bb 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs @@ -283,12 +283,24 @@ public static void SignIn(this IAzureReposBindingManager bindingManager, string if (existingBinding?.GlobalUserName != null && !StringComparer.OrdinalIgnoreCase.Equals(existingBinding.GlobalUserName, userName)) { - bindingManager.Bind(orgName, userName, local: true); + // Global is bound to a different user (B); bind this user locally (-> B | A). + // Skip the write if local is already correct. + if (!StringComparer.OrdinalIgnoreCase.Equals(existingBinding.LocalUserName, userName)) + { + bindingManager.Bind(orgName, userName, local: true); + } } else { - bindingManager.Bind(orgName, userName, local: false); - bindingManager.Unbind(orgName, local: true); + // Global is absent or already matches; ensure global is set and local is clear. + if (existingBinding?.GlobalUserName == null) + { + bindingManager.Bind(orgName, userName, local: false); + } + if (existingBinding?.LocalUserName != null) + { + bindingManager.Unbind(orgName, local: true); + } } } From bffb1aa4a89f47c96b341f03449cfe3a43ea111c Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 23 Mar 2026 10:43:37 +0000 Subject: [PATCH 25/62] docs: update Git placeholder version for NTLM disabled Update the placeholder Git for Windows version number in the note about NTLM being disabled by default. Git for Windows 2.53.0.2 is the first version where NTLM is disabled. Signed-off-by: Matthew John Cheetham --- docs/ntlm-kerberos.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/ntlm-kerberos.md b/docs/ntlm-kerberos.md index 23a055ba4..d359813ef 100644 --- a/docs/ntlm-kerberos.md +++ b/docs/ntlm-kerberos.md @@ -62,7 +62,9 @@ Provider Interface) to perform NTLM and Kerberos authentication. This allows Git to integrate seamlessly with the Windows authentication infrastructure. > [!NOTE] -> As of Git for Windows version 2.XX.X, **NTLM support is disabled by default**. +> As of Git for Windows version 2.53.0.2, **NTLM support is disabled by +> default**. +> > Kerberos support _remains enabled_. ### Re-enabling NTLM Support From e8533eddc4fb4e23564b222315656f029017823e Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 23 Mar 2026 10:47:25 +0000 Subject: [PATCH 26/62] docs: add a section about NTLM-over-SPNEGO Add a section to the NTLM docs explaining the risks of NTLM over SPNEGO, and include a workaround about how to disable NTLM across all of Windows. Signed-off-by: Matthew John Cheetham --- docs/ntlm-kerberos.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/ntlm-kerberos.md b/docs/ntlm-kerberos.md index d359813ef..72cb4d051 100644 --- a/docs/ntlm-kerberos.md +++ b/docs/ntlm-kerberos.md @@ -49,6 +49,30 @@ server to agree on which authentication protocol to use (Kerberos or NTLM) based on their capabilities. Typically Kerberos is preferred if both the client and server support it, with NTLM acting as a fallback. +#### NTLM-over-SPNEGO + +> [!CAUTION] +> When using SPNEGO negotiation if either the client or server does not support +> Kerberos, or if there is an issue with Kerberos authentication, _NTLM may be +> selected as a fallback authentication protocol_. +> +> **This can expose you to all the security risks associated with NTLM.** + +Currently the only way to prevent NTLM from being used as a fallback when SPNEGO +negotiation is in use on Windows is to set the following registry key on your +client system to the value `2` (type `DWORD`) to disable NTLM support +system-wide: + +```text +HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0\RestrictSendingNTLMTraffic +``` + +> [!WARNING] +> Disabling NTLM support system-wide can have unintended consequences. +> +> NTLM is still often used in various legacy applications and services, and +> disabling it may cause authentication failures in those applications. + ## Built-in Support in Git Git provides built-in support for NTLM and Kerberos authentication through the From 5687c1cdbbe5e1a050ecd9665e014cf7795f678e Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Mon, 23 Mar 2026 10:49:16 +0000 Subject: [PATCH 27/62] docs: add workaround for Kerberos-off-by-default bug Add a section to the Kerberos/NTLM docs about an issue in Git that prevents Negotiate authentication from being performed in 'auto' mode. The `http.emptyAuth` setting must be explicitly set to `true`. Signed-off-by: Matthew John Cheetham --- docs/ntlm-kerberos.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ntlm-kerberos.md b/docs/ntlm-kerberos.md index 72cb4d051..46f9cf01f 100644 --- a/docs/ntlm-kerberos.md +++ b/docs/ntlm-kerberos.md @@ -85,6 +85,18 @@ On Windows, Git can use the native Windows [SSPI][sspi-wiki] (Security Support Provider Interface) to perform NTLM and Kerberos authentication. This allows Git to integrate seamlessly with the Windows authentication infrastructure. +> [!IMPORTANT] +> Kerberos authentication is not enabled by default in Git due to a known bug. +> +> To workaround this bug and enable Kerberos authentication, you must set the +> `http..emptyAuth` configuration option to `true` for your remote URL. +> +> For example: +> +> ```shell +> git config --global http.https://example.com.emptyAuth true +> ``` + > [!NOTE] > As of Git for Windows version 2.53.0.2, **NTLM support is disabled by > default**. From 24132dd8c5bd34f1b3f5d9939187d1e64152cf3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:32:40 +0000 Subject: [PATCH 28/62] build(deps): bump DavidAnson/markdownlint-cli2-action Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 22.0.0 to 23.0.0. - [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases) - [Commits](https://github.com/davidanson/markdownlint-cli2-action/compare/07035fd053f7be764496c0f8d8f9f41f98305101...ce4853d43830c74c1753b39f3cf40f71c2031eb9) --- updated-dependencies: - dependency-name: DavidAnson/markdownlint-cli2-action dependency-version: 23.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/lint-docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml index 26d4f4409..bfbd2bbfa 100644 --- a/.github/workflows/lint-docs.yml +++ b/.github/workflows/lint-docs.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: DavidAnson/markdownlint-cli2-action@07035fd053f7be764496c0f8d8f9f41f98305101 + - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 with: globs: | "**/*.md" From efcd7855e458df8dfad2ba35f48961e934244b5f Mon Sep 17 00:00:00 2001 From: Derrick Stolee Date: Fri, 27 Mar 2026 09:35:41 -0400 Subject: [PATCH 29/62] Use 'is null' over '== null' and similar Co-authored-by: Matthew John Cheetham --- src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs index 65006a8bb..2ae7fd11a 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposBindingManager.cs @@ -293,11 +293,11 @@ public static void SignIn(this IAzureReposBindingManager bindingManager, string else { // Global is absent or already matches; ensure global is set and local is clear. - if (existingBinding?.GlobalUserName == null) + if (existingBinding?.GlobalUserName is null) { bindingManager.Bind(orgName, userName, local: false); } - if (existingBinding?.LocalUserName != null) + if (existingBinding?.LocalUserName is not null) { bindingManager.Unbind(orgName, local: true); } From 773d7f9810127dfbf63411fafd8397a0d5020711 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 8 Apr 2026 11:08:12 +0100 Subject: [PATCH 30/62] dotnet: update from .NET 8 to .NET 10 (POSIX) Update our target framework on Mac and Linux to .NET 10, the latest LTS release. Signed-off-by: Matthew John Cheetham --- .vscode/launch.json | 4 ++-- .vscode/tasks.json | 4 ++-- build/GCM.MSBuild.csproj | 2 +- docs/development.md | 4 ++-- src/linux/Packaging.Linux/Packaging.Linux.csproj | 2 +- src/linux/Packaging.Linux/install-from-source.sh | 2 +- src/linux/Packaging.Linux/layout.sh | 2 +- src/osx/Installer.Mac/Installer.Mac.csproj | 2 +- src/osx/Installer.Mac/layout.sh | 2 +- .../Atlassian.Bitbucket.Tests.csproj | 2 +- src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj | 4 ++-- src/shared/Core.Tests/Core.Tests.csproj | 2 +- src/shared/Core/Core.csproj | 4 ++-- src/shared/DotnetTool/DotnetTool.csproj | 2 +- src/shared/DotnetTool/dotnet-tool.nuspec | 2 +- src/shared/DotnetTool/layout.ps1 | 2 +- .../Git-Credential-Manager/Git-Credential-Manager.csproj | 4 ++-- src/shared/GitHub.Tests/GitHub.Tests.csproj | 2 +- src/shared/GitHub/GitHub.csproj | 4 ++-- src/shared/GitLab.Tests/GitLab.Tests.csproj | 2 +- src/shared/GitLab/GitLab.csproj | 4 ++-- .../Microsoft.AzureRepos.Tests.csproj | 2 +- src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj | 4 ++-- src/shared/TestInfrastructure/TestInfrastructure.csproj | 2 +- 24 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index d9b004b86..152dd35b3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net8.0/git-credential-manager.dll", + "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net10.0/git-credential-manager.dll", "args": ["get"], "cwd": "${workspaceFolder}/out/shared/Git-Credential-Manager", "console": "integratedTerminal", @@ -22,7 +22,7 @@ "request": "launch", "preLaunchTask": "build", // If you have changed target frameworks, make sure to update the program path. - "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net8.0/git-credential-manager.dll", + "program": "${workspaceFolder}/out/shared/Git-Credential-Manager/bin/Debug/net10.0/git-credential-manager.dll", "args": ["store"], "cwd": "${workspaceFolder}/out/shared/Git-Credential-Manager", "console": "integratedTerminal", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c559b17b8..8dd1d3737 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -56,7 +56,7 @@ "type": "shell", "group": "test", "args": [ - "~/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll", + "~/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll", "-reports:${workspaceFolder}/**/TestResults/**/coverage.cobertura.xml", "-targetdir:${workspaceFolder}/out/code-coverage" ], @@ -71,7 +71,7 @@ "type": "shell", "group": "test", "args": [ - "${env:USERROFILE}/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll", + "${env:USERROFILE}/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll", "-reports:${workspaceFolder}/**/TestResults/**/coverage.cobertura.xml", "-targetdir:${workspaceFolder}/out/code-coverage" ], diff --git a/build/GCM.MSBuild.csproj b/build/GCM.MSBuild.csproj index 3df4909e6..97871401d 100644 --- a/build/GCM.MSBuild.csproj +++ b/build/GCM.MSBuild.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 false diff --git a/docs/development.md b/docs/development.md index 0242d68b8..9eedda44a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -215,13 +215,13 @@ HTML reports can be generated using ReportGenerator, this should be installed during the build process, from the command line: ```shell -dotnet ~/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage +dotnet ~/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage ``` or ```shell -dotnet {$env:USERPROFILE}/.nuget/packages/reportgenerator/*/*/net8.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage +dotnet {$env:USERPROFILE}/.nuget/packages/reportgenerator/*/*/net10.0/ReportGenerator.dll -reports:./**/TestResults/**/coverage.cobertura.xml -targetdir:./out/code-coverage ``` Or via VSCode Terminal/Run Task: diff --git a/src/linux/Packaging.Linux/Packaging.Linux.csproj b/src/linux/Packaging.Linux/Packaging.Linux.csproj index ddfb31500..b1ec25a99 100644 --- a/src/linux/Packaging.Linux/Packaging.Linux.csproj +++ b/src/linux/Packaging.Linux/Packaging.Linux.csproj @@ -3,7 +3,7 @@ - net8.0 + net10.0 false diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index d323ac9e1..888a23597 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -169,7 +169,7 @@ case "$distribution" in # Install dotnet packages and dependencies if needed. if [ -z "$(verify_existing_dotnet_installation)" ]; then # First try to use native feeds (Ubuntu 22.04 and later). - if ! apt_install dotnet8; then + if ! apt_install dotnet10; then # If the native feeds fail, we fall back to # packages.microsoft.com. We begin by adding the dotnet package # repository/signing key. diff --git a/src/linux/Packaging.Linux/layout.sh b/src/linux/Packaging.Linux/layout.sh index fe3a0f2b8..7d21a9dff 100755 --- a/src/linux/Packaging.Linux/layout.sh +++ b/src/linux/Packaging.Linux/layout.sh @@ -49,7 +49,7 @@ GCM_SRC="$SRC/shared/Git-Credential-Manager" PROJ_OUT="$OUT/linux/Packaging.Linux" # Build parameters -FRAMEWORK=net8.0 +FRAMEWORK=net10.0 # Perform pre-execution checks CONFIGURATION="${CONFIGURATION:=Debug}" diff --git a/src/osx/Installer.Mac/Installer.Mac.csproj b/src/osx/Installer.Mac/Installer.Mac.csproj index daabd20d4..ca872f911 100644 --- a/src/osx/Installer.Mac/Installer.Mac.csproj +++ b/src/osx/Installer.Mac/Installer.Mac.csproj @@ -3,7 +3,7 @@ - net8.0 + net10.0 false diff --git a/src/osx/Installer.Mac/layout.sh b/src/osx/Installer.Mac/layout.sh index ad8e2cfc2..9af3555b8 100755 --- a/src/osx/Installer.Mac/layout.sh +++ b/src/osx/Installer.Mac/layout.sh @@ -24,7 +24,7 @@ GCM_SRC="$SRC/shared/Git-Credential-Manager" GCM_UI_SRC="$SRC/shared/Git-Credential-Manager.UI.Avalonia" # Build parameters -FRAMEWORK=net8.0 +FRAMEWORK=net10.0 # Parse script arguments for i in "$@" diff --git a/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj b/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj index 9e768b91c..caec3fd3d 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj +++ b/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 false true latest diff --git a/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj b/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj index 6aab348f8..a2d5c69bb 100644 --- a/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj +++ b/src/shared/Atlassian.Bitbucket/Atlassian.Bitbucket.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 Atlassian.Bitbucket Atlassian.Bitbucket false diff --git a/src/shared/Core.Tests/Core.Tests.csproj b/src/shared/Core.Tests/Core.Tests.csproj index e3ae7e6b0..0614ca98c 100644 --- a/src/shared/Core.Tests/Core.Tests.csproj +++ b/src/shared/Core.Tests/Core.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 false true latest diff --git a/src/shared/Core/Core.csproj b/src/shared/Core/Core.csproj index f2804177b..21dc3b870 100644 --- a/src/shared/Core/Core.csproj +++ b/src/shared/Core/Core.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 gcmcore GitCredentialManager false diff --git a/src/shared/DotnetTool/DotnetTool.csproj b/src/shared/DotnetTool/DotnetTool.csproj index a1107a4b6..720f75693 100644 --- a/src/shared/DotnetTool/DotnetTool.csproj +++ b/src/shared/DotnetTool/DotnetTool.csproj @@ -1,6 +1,6 @@ - net8.0 + net10.0 true dotnet-tool.nuspec diff --git a/src/shared/DotnetTool/dotnet-tool.nuspec b/src/shared/DotnetTool/dotnet-tool.nuspec index 35f81ebc9..9eb9a7020 100644 --- a/src/shared/DotnetTool/dotnet-tool.nuspec +++ b/src/shared/DotnetTool/dotnet-tool.nuspec @@ -11,7 +11,7 @@ - + diff --git a/src/shared/DotnetTool/layout.ps1 b/src/shared/DotnetTool/layout.ps1 index ca9b13011..f2bd87285 100644 --- a/src/shared/DotnetTool/layout.ps1 +++ b/src/shared/DotnetTool/layout.ps1 @@ -44,7 +44,7 @@ $DotnetToolRel = "shared/DotnetTool" $GcmSrc = Join-Path $Src "shared\Git-Credential-Manager" $ProjOut = Join-Path $Out $DotnetToolRel -$Framework = "net8.0" +$Framework = "net10.0" if (-not $Output -or $Output.Trim() -eq "") { $Output = Join-Path $ProjOut "nupkg\$Configuration" diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj index 8c469897e..b367bb48a 100644 --- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj +++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj @@ -2,8 +2,8 @@ Exe - net8.0 - net472;net8.0 + net10.0 + net472;net10.0 win-x86;win-x64;win-arm64;osx-x64;linux-x64;osx-arm64;linux-arm64;linux-arm git-credential-manager GitCredentialManager diff --git a/src/shared/GitHub.Tests/GitHub.Tests.csproj b/src/shared/GitHub.Tests/GitHub.Tests.csproj index 0574e00d1..28f73e6f0 100644 --- a/src/shared/GitHub.Tests/GitHub.Tests.csproj +++ b/src/shared/GitHub.Tests/GitHub.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 false true latest diff --git a/src/shared/GitHub/GitHub.csproj b/src/shared/GitHub/GitHub.csproj index 66a4afd79..53ac5cf94 100644 --- a/src/shared/GitHub/GitHub.csproj +++ b/src/shared/GitHub/GitHub.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 GitHub GitHub false diff --git a/src/shared/GitLab.Tests/GitLab.Tests.csproj b/src/shared/GitLab.Tests/GitLab.Tests.csproj index 098878aec..3c28af20a 100644 --- a/src/shared/GitLab.Tests/GitLab.Tests.csproj +++ b/src/shared/GitLab.Tests/GitLab.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 false true latest diff --git a/src/shared/GitLab/GitLab.csproj b/src/shared/GitLab/GitLab.csproj index 25c37f2fe..19aa356f2 100644 --- a/src/shared/GitLab/GitLab.csproj +++ b/src/shared/GitLab/GitLab.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 GitLab GitLab false diff --git a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj index 1c673bcc9..b9cee559f 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj +++ b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 false true latest diff --git a/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj b/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj index eaf866bfa..73b7dedff 100644 --- a/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj +++ b/src/shared/Microsoft.AzureRepos/Microsoft.AzureRepos.csproj @@ -1,8 +1,8 @@  - net8.0 - net8.0;net472 + net10.0 + net10.0;net472 Microsoft.AzureRepos Microsoft.AzureRepos false diff --git a/src/shared/TestInfrastructure/TestInfrastructure.csproj b/src/shared/TestInfrastructure/TestInfrastructure.csproj index 63f6fee89..aa4dd2e55 100644 --- a/src/shared/TestInfrastructure/TestInfrastructure.csproj +++ b/src/shared/TestInfrastructure/TestInfrastructure.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 GitCredentialManager.Tests false false From 3f7800f555dc24f81e0f3519af4dcdd4aa21b2fd Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 8 Apr 2026 11:24:17 +0100 Subject: [PATCH 31/62] build: use Central Package Management Let's move to use CPM (Central Package Management) to make it easier to update dependencies across all our projects. Keep the current versions at the moment - this is just moving to CPM. Signed-off-by: Matthew John Cheetham --- Directory.Build.props | 4 +- Directory.Packages.props | 40 +++++++++++++++++++ build/GCM.MSBuild.csproj | 4 +- .../Atlassian.Bitbucket.Tests.csproj | 8 ++-- src/shared/Core.Tests/Core.Tests.csproj | 8 ++-- src/shared/Core/Core.csproj | 20 +++++----- src/shared/GitHub.Tests/GitHub.Tests.csproj | 8 ++-- src/shared/GitLab.Tests/GitLab.Tests.csproj | 8 ++-- .../Microsoft.AzureRepos.Tests.csproj | 8 ++-- .../TestInfrastructure.csproj | 8 ++-- .../Installer.Windows.csproj | 7 ++-- 11 files changed, 80 insertions(+), 43 deletions(-) create mode 100644 Directory.Packages.props diff --git a/Directory.Build.props b/Directory.Build.props index e7ed76eb9..58e8f8770 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -27,9 +27,7 @@ - - 8.0.5 - + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 000000000..a836e1bca --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,40 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/GCM.MSBuild.csproj b/build/GCM.MSBuild.csproj index 97871401d..bd25f8345 100644 --- a/build/GCM.MSBuild.csproj +++ b/build/GCM.MSBuild.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj b/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj index caec3fd3d..b3072b96d 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj +++ b/src/shared/Atlassian.Bitbucket.Tests/Atlassian.Bitbucket.Tests.csproj @@ -8,13 +8,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/Core.Tests/Core.Tests.csproj b/src/shared/Core.Tests/Core.Tests.csproj index 0614ca98c..64a1e2437 100644 --- a/src/shared/Core.Tests/Core.Tests.csproj +++ b/src/shared/Core.Tests/Core.Tests.csproj @@ -9,13 +9,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/Core/Core.csproj b/src/shared/Core/Core.csproj index 21dc3b870..cdfd08deb 100644 --- a/src/shared/Core/Core.csproj +++ b/src/shared/Core/Core.csproj @@ -13,25 +13,25 @@ - - + + - + - - - - - - + + + + + + - + diff --git a/src/shared/GitHub.Tests/GitHub.Tests.csproj b/src/shared/GitHub.Tests/GitHub.Tests.csproj index 28f73e6f0..cf7f8be69 100644 --- a/src/shared/GitHub.Tests/GitHub.Tests.csproj +++ b/src/shared/GitHub.Tests/GitHub.Tests.csproj @@ -8,13 +8,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/GitLab.Tests/GitLab.Tests.csproj b/src/shared/GitLab.Tests/GitLab.Tests.csproj index 3c28af20a..aef8483a1 100644 --- a/src/shared/GitLab.Tests/GitLab.Tests.csproj +++ b/src/shared/GitLab.Tests/GitLab.Tests.csproj @@ -8,13 +8,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj index b9cee559f..e9cb3f16d 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj +++ b/src/shared/Microsoft.AzureRepos.Tests/Microsoft.AzureRepos.Tests.csproj @@ -8,13 +8,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/shared/TestInfrastructure/TestInfrastructure.csproj b/src/shared/TestInfrastructure/TestInfrastructure.csproj index aa4dd2e55..9c3e96e5e 100644 --- a/src/shared/TestInfrastructure/TestInfrastructure.csproj +++ b/src/shared/TestInfrastructure/TestInfrastructure.csproj @@ -9,10 +9,10 @@ - - - - + + + + diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj index ec678fe5f..ace93b63e 100644 --- a/src/windows/Installer.Windows/Installer.Windows.csproj +++ b/src/windows/Installer.Windows/Installer.Windows.csproj @@ -14,7 +14,6 @@ false false $(PlatformOutPath)Installer.Windows\bin\$(Configuration)\net472\$(RuntimeIdentifier)\ - 6.3.1 false @@ -29,7 +28,7 @@ - + @@ -37,8 +36,8 @@ - "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" - "$(NuGetPackageRoot)Tools.InnoSetup\$(InnoSetupVersion)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" + "$(PkgTools_InnoSetup)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=system /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" + "$(PkgTools_InnoSetup)\tools\ISCC.exe" /DPayloadDir="$(PayloadPath)" /DInstallTarget=user /DGcmRuntimeIdentifier="$(RuntimeIdentifier)" "$(RepoSrcPath)\windows\Installer.Windows\Setup.iss" /O"$(OutputPath)" From 1fd451c0b6a83e69974a3be285ba2a7a80b01b9e Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 8 Apr 2026 11:35:07 +0100 Subject: [PATCH 32/62] ci: update to .NET 10 for CI and release builds Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 16 ++++++++-------- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/continuous-integration.yml | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 797a42d73..68808aa67 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -136,10 +136,10 @@ extends: $version = (Get-Content .\VERSION) -replace '\.\d+$', '' Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: PowerShell@2 displayName: 'Build payload' inputs: @@ -296,10 +296,10 @@ extends: script: | echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: Bash@3 displayName: 'Build payload' inputs: @@ -565,10 +565,10 @@ extends: script: | echo "##vso[task.setvariable variable=version;isReadOnly=true]$(cat ./VERSION | sed -E 's/.[0-9]+$//')" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: Bash@3 displayName: 'Build payload' inputs: @@ -664,10 +664,10 @@ extends: $version = (Get-Content .\VERSION) -replace '\.\d+$', '' Write-Host "##vso[task.setvariable variable=version;isReadOnly=true]$version" - task: UseDotNet@2 - displayName: 'Use .NET 8 SDK' + displayName: 'Use .NET 10 SDK' inputs: packageType: sdk - version: '8.x' + version: '10.x' - task: NuGetToolInstaller@1 displayName: 'Install NuGet CLI' inputs: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 2fd38db60..d2b88817b 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -27,7 +27,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5.1.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index c59f623f7..ec9f0c20f 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -30,7 +30,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5.1.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Install dependencies run: dotnet restore @@ -78,7 +78,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5.1.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Install dependencies run: dotnet restore @@ -122,7 +122,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5.1.0 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x - name: Install dependencies run: dotnet restore From bea97dae5a9c4c1309ca99152720152eb3a9e40b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 8 Apr 2026 11:59:48 +0100 Subject: [PATCH 33/62] ci: install x86 .NET 10 runtime on Windows x86 builds The x86 test host requires an x86 .NET runtime, which isn't pre-installed on the GitHub runner, nor can the actions/setup-dotnet action install it (only the native arch SDK is installed). Install it manually using the official installer script. Signed-off-by: Matthew John Cheetham --- .github/workflows/continuous-integration.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index ec9f0c20f..a77a8d66d 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -32,6 +32,15 @@ jobs: with: dotnet-version: 10.0.x + # The x86 test host requires an x86 .NET runtime, which isn't pre-installed + # on the runner, nor can the actions/setup-dotnet action install it. + # Install it manually so tests can run. + - name: Setup .NET (x86) + if: matrix.runtime == 'win-x86' + run: | + Invoke-WebRequest 'https://dot.net/v1/dotnet-install.ps1' -OutFile dotnet-install.ps1 + ./dotnet-install.ps1 -Channel 10.0 -Architecture x86 -InstallDir 'C:\Program Files (x86)\dotnet' + - name: Install dependencies run: dotnet restore From a25aa2d5899666cbaa58bef5cfed226ab72d7606 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 12 Mar 2026 15:55:18 +0000 Subject: [PATCH 34/62] wif: Add implementation of Workload Identity Federation for AzRepos Add support for Workload Identity Federation (WIF) for Azure Repos. This enables users to authenticate to Azure Repos using federated tokens from Managed Identities, GitHub Actions, or generic identity providers. We support three scenarios: 1. Generic When you have a pre-obtained client assertion token from any external identity provider. You provide the assertion directly and GCM exchanges it for an access token. 2. Entra ID Managed Identities When your workload runs on an Azure resource that has a Managed Identity assigned. GCM will first request a token from the Managed Identity for the configured audience, then exchange that token for an Azure DevOps access token. 3. GitHub Actions When your workload runs in a GitHub Actions workflow. GCM will automatically obtain an OIDC token from the GitHub Actions runtime and exchange it for an Azure DevOps access token. Signed-off-by: Matthew John Cheetham --- .../Authentication/MicrosoftAuthentication.cs | 112 +++++++- .../MicrosoftWorkloadFederationOptions.cs | 77 ++++++ src/shared/Core/Constants.cs | 5 + .../AzureReposHostProviderTests.cs | 242 ++++++++++++++++++ .../AzureDevOpsConstants.cs | 12 + .../AzureReposHostProvider.cs | 171 +++++++++++++ 6 files changed, 617 insertions(+), 2 deletions(-) create mode 100644 src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs diff --git a/src/shared/Core/Authentication/MicrosoftAuthentication.cs b/src/shared/Core/Authentication/MicrosoftAuthentication.cs index 5e2eca63c..5d65fa982 100644 --- a/src/shared/Core/Authentication/MicrosoftAuthentication.cs +++ b/src/shared/Core/Authentication/MicrosoftAuthentication.cs @@ -9,6 +9,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensions.Msal; using System.Text; +using System.Text.Json; using System.Threading; using GitCredentialManager.UI; using GitCredentialManager.UI.Controls; @@ -63,6 +64,14 @@ Task GetTokenForUserAsync(string authority, stri /// - "resource://{guid}" - Use the user-assigned managed identity with resource ID {guid}. /// Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource); + + /// + /// Acquire a token using workload federation. + /// + /// An object containing configuration workload federation. + /// Scopes to request. + /// Authentication result including access token. + Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes); } public class ServicePrincipalIdentity @@ -287,7 +296,8 @@ public async Task GetTokenForServicePrincipalAsy } } - public async Task GetTokenForManagedIdentityAsync(string managedIdentity, string resource) + public async Task GetTokenForManagedIdentityAsync( + string managedIdentity, string resource) { var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); @@ -306,8 +316,88 @@ public async Task GetTokenForManagedIdentityAsyn { Context.Trace.WriteLine(mid == ManagedIdentityId.SystemAssigned ? "Failed to acquire token for system managed identity." - : $"Failed to acquire token for user managed identity '{managedIdentity:D}'."); + : $"Failed to acquire token for user managed identity '{managedIdentity}'."); + Context.Trace.WriteException(ex); + throw; + } + } + + public async Task GetTokenUsingWorkloadFederationAsync(MicrosoftWorkloadFederationOptions fedOpts, string[] scopes) + { + IConfidentialClientApplication app = await CreateConfidentialClientApplicationAsync(fedOpts); + + AuthenticationResult result = await app.AcquireTokenForClient(scopes) + .ExecuteAsync() + .ConfigureAwait(false); + + return new MsalResult(result); + } + + private async Task GetClientAssertion(MicrosoftWorkloadFederationOptions fedOpts, AssertionRequestOptions _) + { + switch (fedOpts.Scenario) + { + case MicrosoftWorkloadFederationScenario.Generic: + Context.Trace.WriteLine("Getting client assertion for generic workload federation scenario..."); + if (string.IsNullOrWhiteSpace(fedOpts.GenericClientAssertion)) + throw new InvalidOperationException( + "Client assertion must be provided for generic workload federation scenario."); + return fedOpts.GenericClientAssertion; + + case MicrosoftWorkloadFederationScenario.ManagedIdentity: + Context.Trace.WriteLine("Getting client assertion for managed identity workload federation scenario..."); + var miResult = await GetTokenForManagedIdentityAsync(fedOpts.ManagedIdentityId, fedOpts.Audience); + return miResult.AccessToken; + + case MicrosoftWorkloadFederationScenario.GitHubActions: + Context.Trace.WriteLine("Getting client assertion for GitHub Actions workload federation scenario..."); + return await GetGitHubOidcToken(fedOpts.GitHubTokenRequestUrl, fedOpts.Audience, fedOpts.GitHubTokenRequestToken); + + default: + throw new ArgumentOutOfRangeException(nameof(fedOpts.Scenario), fedOpts.Scenario, "Unsupported workload federation scenario."); + } + } + + private async Task GetGitHubOidcToken(Uri requestUri, string audience, string requestToken) + { + using HttpClient http = Context.HttpClientFactory.CreateClient(); + + UriBuilder ub = new UriBuilder(requestUri); + if (ub.Query.Length > 0) ub.Query += "&"; + ub.Query += $"audience={Uri.EscapeDataString(audience)}"; + + using var request = new HttpRequestMessage(HttpMethod.Get, ub.Uri); + request.AddBearerAuthenticationHeader(requestToken); + + Context.Trace.WriteLine($"Requesting GitHub OIDC token from '{request.RequestUri}'..."); + Context.Trace.WriteLineSecrets("OIDC request token: {0}", new[] { requestToken }); + using HttpResponseMessage response = await http.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + string error = await response.Content.ReadAsStringAsync(); + Context.Trace.WriteLine($"Failed to acquire GitHub OIDC token [{response.StatusCode:D} {response.StatusCode}]: {error}"); + response.EnsureSuccessStatusCode(); + } + + string json = await response.Content.ReadAsStringAsync(); + + try + { + using JsonDocument jsonDoc = JsonDocument.Parse(json); + if (!jsonDoc.RootElement.TryGetProperty("value", out JsonElement tokenElement)) + { + throw new InvalidOperationException( + "Invalid response from GitHub OIDC token endpoint: 'value' property not found."); + } + + return tokenElement.GetString() ?? + throw new InvalidOperationException( + "Invalid response from GitHub OIDC token endpoint: 'value' property is null."); + } + catch (Exception ex) + { Context.Trace.WriteException(ex); + Context.Trace.WriteLine($"OIDC token response: {json}"); throw; } } @@ -558,6 +648,24 @@ private async Task CreateConfidentialClientAppli return app; } + private async Task CreateConfidentialClientApplicationAsync( + MicrosoftWorkloadFederationOptions fedOpts) + { + var httpFactoryAdaptor = new MsalHttpClientFactoryAdaptor(Context.HttpClientFactory); + + Context.Trace.WriteLine($"Creating federated confidential client application for {fedOpts.TenantId}/{fedOpts.ClientId}..."); + var appBuilder = ConfidentialClientApplicationBuilder.Create(fedOpts.ClientId) + .WithTenantId(fedOpts.TenantId) + .WithHttpClientFactory(httpFactoryAdaptor) + .WithClientAssertion(reqOpts => GetClientAssertion(fedOpts, reqOpts)); + + IConfidentialClientApplication app = appBuilder.Build(); + + await RegisterTokenCacheAsync(app.AppTokenCache, CreateAppTokenCacheProps, Context.Trace2); + + return app; + } + #endregion #region Helpers diff --git a/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs new file mode 100644 index 000000000..5511c0dee --- /dev/null +++ b/src/shared/Core/Authentication/MicrosoftWorkloadFederationOptions.cs @@ -0,0 +1,77 @@ +using System; + +namespace GitCredentialManager.Authentication; + +public enum MicrosoftWorkloadFederationScenario +{ + /// + /// Federate via pre-computed client assertion. + /// + Generic, + + /// + /// Federate via an access token for an Entra ID Managed Identity. + /// + ManagedIdentity, + + /// + /// Federate via a GitHub Actions OIDC token. + /// + GitHubActions, +} + +public class MicrosoftWorkloadFederationOptions +{ + public const string DefaultAudience = Constants.DefaultWorkloadFederationAudience; + + private string _audience = DefaultAudience; + + /// + /// The workload federation scenario to use. + /// + public MicrosoftWorkloadFederationScenario Scenario { get; set; } + + /// + /// Tenant ID of the identity to request an access token for. + /// + public string TenantId { get; set; } + + /// + /// Client ID of the identity to request an access token for. + /// + public string ClientId { get; set; } + + /// + /// The audience to use when requesting a token. + /// + /// If this is null, the default audience will be used. + public string Audience + { + get => _audience; + set => _audience = value ?? DefaultAudience; + } + + /// + /// Generic assertion. + /// + /// Used with the federation scenario. + public string GenericClientAssertion { get; set; } + + /// + /// The managed identity to request a federated token for, to exchange for an access token. + /// + /// Used with the federation scenario. + public string ManagedIdentityId { get; set; } + + /// + /// GitHub Actions OIDC token request URI. + /// + /// Used with the federation scenario. + public Uri GitHubTokenRequestUrl { get; set; } + + /// + /// GitHub Actions OIDC token request token. + /// + /// Used with the federation scenario. + public string GitHubTokenRequestToken { get; set; } +} diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index e6625a6ea..6fecc2b38 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -31,6 +31,8 @@ public static class Constants /// public static readonly Guid MsaTransferTenantId = new("f8cdef31-a31e-4b4a-93e4-5f571e91255a"); + public const string DefaultWorkloadFederationAudience = "api://AzureADTokenExchange"; + public static class CredentialProtocol { public const string NtlmKey = "ntlm"; @@ -130,6 +132,9 @@ public static class EnvironmentVariables public const string GcmDevUseLegacyUiHelpers = "GCM_DEV_USELEGACYUIHELPERS"; public const string GcmGuiSoftwareRendering = "GCM_GUI_SOFTWARE_RENDERING"; public const string GcmAllowUnsafeRemotes = "GCM_ALLOW_UNSAFE_REMOTES"; + + public const string GitHubActionsTokenRequestUrl = "ACTIONS_ID_TOKEN_REQUEST_URL"; + public const string GitHubActionsTokenRequestToken = "ACTIONS_ID_TOKEN_REQUEST_TOKEN"; } public static class Http diff --git a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs index bfd14d14f..e05db1646 100644 --- a/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs +++ b/src/shared/Microsoft.AzureRepos.Tests/AzureReposHostProviderTests.cs @@ -605,6 +605,248 @@ public async Task AzureReposProvider_GetCredentialAsync_ManagedIdentity_ReturnsM AzureDevOpsConstants.AzureDevOpsResourceId), Times.Once); } + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_Generic_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "generic"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string assertion = "CLIENT-ASSERTION"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = assertion, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.GenericClientAssertion == assertion), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GenericFileAssertion_ReadsFromFile() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "generic"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string assertion = "CLIENT-ASSERTION-FROM-FILE"; + const string filePath = "/tmp/assertion-token.txt"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion] = $"file://{filePath}", + } + } + }; + + context.FileSystem.Files[filePath] = System.Text.Encoding.UTF8.GetBytes(assertion); + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.Generic && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.GenericClientAssertion == assertion), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_MI_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "managedidentity"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string managedIdentity = "22222222-2222-2222-2222-222222222222"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity] = managedIdentity, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.ManagedIdentity && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience && + fed.ManagedIdentityId == managedIdentity), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + + [Fact] + public async Task AzureReposProvider_GetCredentialAsync_WorkloadFederation_GitHubActions_ReturnsFederationOptions() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "dev.azure.com", + ["path"] = "org/proj/_git/repo" + }); + + const string accessToken = "FEDERATED-IDENTITY-TOKEN"; + const string wifScenario = "githubactions"; + const string tenantId = "00000000-0000-0000-0000-000000000000"; + const string clientId = "11111111-1111-1111-1111-111111111111"; + const string ghRequestUrl = "https://token.actions.example.com/oidc/example?param=value"; + const string ghRequestToken = "OIDC-TOKEN"; + + var context = new TestCommandContext + { + Environment = + { + Variables = + { + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation] = wifScenario, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId] = tenantId, + [AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId] = clientId, + [Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl] = ghRequestUrl, + [Constants.EnvironmentVariables.GitHubActionsTokenRequestToken] = ghRequestToken, + } + } + }; + + var azDevOps = Mock.Of(); + var authorityCache = Mock.Of(); + var userMgr = Mock.Of(); + var msAuthMock = new Mock(); + + msAuthMock.Setup(x => x.GetTokenUsingWorkloadFederationAsync( + It.IsAny(), It.IsAny())) + .ReturnsAsync(new MockMsAuthResult { AccessToken = accessToken }); + + var provider = new AzureReposHostProvider(context, azDevOps, msAuthMock.Object, authorityCache, userMgr); + + GetCredentialResult result = await provider.GetCredentialAsync(input); + ICredential credential = result.Credential; + + Assert.NotNull(credential); + Assert.Equal(clientId, credential.Account); + Assert.Equal(accessToken, credential.Password); + + msAuthMock.Verify( + x => x.GetTokenUsingWorkloadFederationAsync( + It.Is( + fed => fed.Scenario == MicrosoftWorkloadFederationScenario.GitHubActions && + fed.TenantId == tenantId && + fed.ClientId == clientId && + fed.GitHubTokenRequestUrl == new Uri(ghRequestUrl) && + fed.GitHubTokenRequestToken == ghRequestToken && + fed.Audience == MicrosoftWorkloadFederationOptions.DefaultAudience), + AzureDevOpsConstants.AzureDevOpsDefaultScopes), Times.Once); + } + [Fact] public async Task AzureReposProvider_GetCredentialAsync_ServicePrincipal_ReturnsSPCredential() { diff --git a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs index a282d4eff..f9ec8ce0f 100644 --- a/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs +++ b/src/shared/Microsoft.AzureRepos/AzureDevOpsConstants.cs @@ -46,6 +46,12 @@ public static class EnvironmentVariables public const string ServicePrincipalCertificateThumbprint = "GCM_AZREPOS_SP_CERT_THUMBPRINT"; public const string ServicePrincipalCertificateSendX5C = "GCM_AZREPOS_SP_CERT_SEND_X5C"; public const string ManagedIdentity = "GCM_AZREPOS_MANAGEDIDENTITY"; + public const string WorkloadFederation = "GCM_AZREPOS_WIF"; + public const string WorkloadFederationClientId = "GCM_AZREPOS_WIF_CLIENTID"; + public const string WorkloadFederationTenantId = "GCM_AZREPOS_WIF_TENANTID"; + public const string WorkloadFederationAudience = "GCM_AZREPOS_WIF_AUDIENCE"; + public const string WorkloadFederationAssertion = "GCM_AZREPOS_WIF_ASSERTION"; + public const string WorkloadFederationManagedIdentity = "GCM_AZREPOS_WIF_MANAGEDIDENTITY"; } public static class GitConfiguration @@ -62,6 +68,12 @@ public static class Credential public const string ServicePrincipalCertificateThumbprint = "azreposServicePrincipalCertificateThumbprint"; public const string ServicePrincipalCertificateSendX5C = "azreposServicePrincipalCertificateSendX5C"; public const string ManagedIdentity = "azreposManagedIdentity"; + public const string WorkloadFederation = "azreposWorkloadFederation"; + public const string WorkloadFederationClientId = "azreposWorkloadFederationClientId"; + public const string WorkloadFederationTenantId = "azreposWorkloadFederationTenantId"; + public const string WorkloadFederationAudience = "azreposWorkloadFederationAudience"; + public const string WorkloadFederationAssertion = "azreposWorkloadFederationAssertion"; + public const string WorkloadFederationManagedIdentity = "azreposWorkloadFederationManagedIdentity"; } } } diff --git a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index 72eb378eb..9a916a236 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -85,6 +85,15 @@ public async Task GetCredentialAsync(InputArguments input) ); } + if (UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts)) + { + _context.Trace.WriteLine($"Getting Azure Access Token using WIF (scenario: {fedOpts.Scenario})..."); + var azureResult = await _msAuth.GetTokenUsingWorkloadFederationAsync(fedOpts, AzureDevOpsConstants.AzureDevOpsDefaultScopes); + return new GetCredentialResult( + new GitCredential(fedOpts.ClientId, azureResult.AccessToken) + ); + } + if (UseServicePrincipal(out ServicePrincipalIdentity sp)) { _context.Trace.WriteLine($"Getting Azure Access Token for service principal {sp.TenantId}/{sp.Id}..."); @@ -137,6 +146,10 @@ public Task StoreCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to store for managed identity authentication."); } + else if (UseWorkloadFederation(out _)) + { + _context.Trace.WriteLine("Nothing to store for federated identity authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to store for service principal authentication."); @@ -172,6 +185,10 @@ public Task EraseCredentialAsync(InputArguments input) { _context.Trace.WriteLine("Nothing to erase for managed identity authentication."); } + else if (UseWorkloadFederation(out _)) + { + _context.Trace.WriteLine("Nothing to erase for federated identity authentication."); + } else if (UseServicePrincipal(out _)) { _context.Trace.WriteLine("Nothing to erase for service principal authentication."); @@ -588,6 +605,160 @@ private bool UseManagedIdentity(out string mid) !string.IsNullOrWhiteSpace(mid); } + private bool UseWorkloadFederation(out MicrosoftWorkloadFederationOptions fedOpts) + { + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederation, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederation, + out string wifStr)) + { + fedOpts = null; + return false; + } + + MicrosoftWorkloadFederationScenario scenario; + switch (wifStr.ToLowerInvariant()) + { + case "generic": + scenario = MicrosoftWorkloadFederationScenario.Generic; + break; + + case "mi": + case "managedidentity": + scenario = MicrosoftWorkloadFederationScenario.ManagedIdentity; + break; + + case "github": + case "githubactions": + scenario = MicrosoftWorkloadFederationScenario.GitHubActions; + break; + + default: // Unknown scenario value + fedOpts = null; + return false; + } + + bool hasClientId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationClientId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationClientId, + out string clientId); + + bool hasTenantId = _context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationTenantId, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationTenantId, + out string tenantId); + + if (!hasClientId || !hasTenantId) + { + _context.Streams.Error.WriteLine("error: both client ID and tenant ID are required for workload federation"); + fedOpts = null; + return false; + } + + // Audience is optional - the default is "api://AzureADTokenExchange" + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAudience, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAudience, + out string audience) || string.IsNullOrWhiteSpace(audience)) + { + audience = MicrosoftWorkloadFederationOptions.DefaultAudience; + } + + fedOpts = new MicrosoftWorkloadFederationOptions + { + Scenario = scenario, + ClientId = clientId, + TenantId = tenantId, + Audience = audience + }; + + switch (scenario) + { + case MicrosoftWorkloadFederationScenario.Generic: + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationAssertion, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationAssertion, + out string assertion) || string.IsNullOrWhiteSpace(assertion)) + { + _context.Streams.Error.WriteLine("error: assertion is required for the generic workload federation scenario"); + fedOpts = null; + return false; + } + + // Check if this value points to a file containing the actual assertion (file://) + if (Uri.TryCreate(assertion, UriKind.Absolute, out Uri assertionUri) + && StringComparer.OrdinalIgnoreCase.Equals(assertionUri.Scheme, "file")) + { + string filePath = assertionUri.LocalPath; + if (!_context.FileSystem.FileExists(filePath)) + { + _context.Streams.Error.WriteLine($"error: assertion file not found: {filePath}"); + fedOpts = null; + return false; + } + + _context.Trace.WriteLine($"Reading workload federation assertion from file '{filePath}'..."); + assertion = _context.FileSystem.ReadAllText(filePath).Trim(); + if (string.IsNullOrWhiteSpace(assertion)) + { + _context.Streams.Error.WriteLine($"error: assertion file is empty: {filePath}"); + fedOpts = null; + return false; + } + } + + fedOpts.GenericClientAssertion = assertion; + break; + + case MicrosoftWorkloadFederationScenario.ManagedIdentity: + if (!_context.Settings.TryGetSetting( + AzureDevOpsConstants.EnvironmentVariables.WorkloadFederationManagedIdentity, + Constants.GitConfiguration.Credential.SectionName, + AzureDevOpsConstants.GitConfiguration.Credential.WorkloadFederationManagedIdentity, + out string managedIdentity) || string.IsNullOrWhiteSpace(managedIdentity)) + { + _context.Streams.Error.WriteLine("error: managed identity is required for the managed identity workload federation scenario"); + fedOpts = null; + return false; + } + + fedOpts.ManagedIdentityId = managedIdentity; + break; + + case MicrosoftWorkloadFederationScenario.GitHubActions: + if (!_context.Environment.Variables.TryGetValue( + Constants.EnvironmentVariables.GitHubActionsTokenRequestUrl, out string tokenRequestUrl) + || !Uri.TryCreate(tokenRequestUrl, UriKind.Absolute, out Uri tokenRequestUri)) + { + _context.Streams.Error.WriteLine( + "error: unable to get valid token request URL from environment variable for the GitHub Actions workload federation scenario"); + fedOpts = null; + return false; + } + + if (!_context.Environment.Variables.TryGetValue( + Constants.EnvironmentVariables.GitHubActionsTokenRequestToken, out string tokenRequestToken) + || string.IsNullOrWhiteSpace(tokenRequestToken)) + { + _context.Streams.Error.WriteLine( + "error: unable to get valid token request token from environment variable for the GitHub Actions workload federation scenario"); + fedOpts = null; + return false; + } + + fedOpts.GitHubTokenRequestUrl = tokenRequestUri; + fedOpts.GitHubTokenRequestToken = tokenRequestToken; + break; + } + + return true; + } + #endregion #region IConfigurationComponent From 9c38f6a9698be22983eba2713f66c8e9bef8d893 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 12 Mar 2026 15:55:43 +0000 Subject: [PATCH 35/62] wif: add documentation for WIF Signed-off-by: Matthew John Cheetham --- docs/azrepos-wif.md | 185 ++++++++++++++++++++++++++++++++++++++++++ docs/configuration.md | 146 +++++++++++++++++++++++++++++++++ docs/environment.md | 180 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 docs/azrepos-wif.md diff --git a/docs/azrepos-wif.md b/docs/azrepos-wif.md new file mode 100644 index 000000000..0e8f0147e --- /dev/null +++ b/docs/azrepos-wif.md @@ -0,0 +1,185 @@ +# Azure Workload Identity Federation + +Git Credential Manager supports [Workload Identity Federation][wif] for +authentication with Azure Repos. This document provides an overview of Workload +Identity Federation and how to use it with GCM. + +## Overview + +Workload Identity Federation allows a workload (such as a CI/CD pipeline, VM, or +container) to exchange a token from an external identity provider for a Microsoft +Entra ID access token — without needing to manage secrets like client secrets or +certificates. + +This is especially useful in scenarios where: + +- You want to avoid storing long-lived secrets. +- Your workload already has an identity token from another provider (e.g., GitHub + Actions OIDC, a Managed Identity, or a custom identity provider). +- You want to follow the principle of least privilege with short-lived, + automatically rotated credentials. + +You can read more about Workload Identity Federation in the +[Microsoft Entra documentation][wif]. + +## How it works + +When configured, GCM obtains a client assertion (a token from the external +identity provider) and exchanges it with Microsoft Entra ID for an access token +scoped to Azure DevOps. The exact mechanism for obtaining the client assertion +depends on the federation scenario you choose. + +## Scenarios + +GCM supports three federation scenarios: + +### Generic + +Use this scenario when you have a pre-obtained client assertion token from any +external identity provider. You provide the assertion directly and GCM exchanges +it for an access token. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] +Assertion|[`credential.azreposWorkloadFederationAssertion`][gcm-wif-assertion-config]|[`GCM_AZREPOS_WIF_ASSERTION`][gcm-wif-assertion-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation generic +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +git config --global credential.azreposWorkloadFederationAssertion "eyJhbGci..." +``` + +### Managed Identity + +Use this scenario when your workload runs on an Azure resource that has a +[Managed Identity][az-mi] assigned. GCM will first request a token from the +Managed Identity for the configured audience, then exchange that token for an +Azure DevOps access token. + +This is useful for Azure VMs, App Services, or other Azure resources that have a +Managed Identity but need to authenticate as a specific app registration with +a federated credential trust. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] +Managed Identity|[`credential.azreposWorkloadFederationManagedIdentity`][gcm-wif-mi-config]|[`GCM_AZREPOS_WIF_MANAGEDIDENTITY`][gcm-wif-mi-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +The Managed Identity value accepts the same formats as +[`credential.azreposManagedIdentity`][gcm-mi-config]: + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation managedidentity +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +git config --global credential.azreposWorkloadFederationManagedIdentity system +``` + +### GitHub Actions + +Use this scenario when your workload runs in a GitHub Actions workflow. GCM will +automatically obtain an OIDC token from the GitHub Actions runtime and exchange +it for an Azure DevOps access token. + +This scenario uses the `ACTIONS_ID_TOKEN_REQUEST_URL` and +`ACTIONS_ID_TOKEN_REQUEST_TOKEN` environment variables that GitHub Actions +automatically provides when a workflow has the `id-token: write` permission. + +**Required settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Scenario|[`credential.azreposWorkloadFederation`][gcm-wif-config]|[`GCM_AZREPOS_WIF`][gcm-wif-env] +Client ID|[`credential.azreposWorkloadFederationClientId`][gcm-wif-clientid-config]|[`GCM_AZREPOS_WIF_CLIENTID`][gcm-wif-clientid-env] +Tenant ID|[`credential.azreposWorkloadFederationTenantId`][gcm-wif-tenantid-config]|[`GCM_AZREPOS_WIF_TENANTID`][gcm-wif-tenantid-env] + +**Optional settings:** + +Setting|Git Configuration|Environment Variable +-|-|- +Audience|[`credential.azreposWorkloadFederationAudience`][gcm-wif-audience-config]|[`GCM_AZREPOS_WIF_AUDIENCE`][gcm-wif-audience-env] + +No additional GCM settings are required — the GitHub Actions OIDC environment +variables are read automatically. + +#### Prerequisites + +1. An app registration in Microsoft Entra ID with a federated credential + configured to trust your GitHub repository. +2. The app registration must have the necessary permissions to access Azure + DevOps. +3. Your GitHub Actions workflow must have the `id-token: write` permission. + +#### Example workflow + +```yaml +permissions: + id-token: write + contents: read + +steps: + - uses: actions/checkout@v4 + env: + GCM_AZREPOS_WIF: githubactions + GCM_AZREPOS_WIF_CLIENTID: "11111111-1111-1111-1111-111111111111" + GCM_AZREPOS_WIF_TENANTID: "22222222-2222-2222-2222-222222222222" +``` + +## Audience + +All scenarios accept an optional audience setting that controls the audience +claim in the federated token request. The default value is +`api://AzureADTokenExchange`, which is the standard audience for Microsoft Entra +ID workload identity federation. + +You only need to change this if your federated credential trust is configured +with a custom audience. + +[az-mi]: https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation +[gcm-mi-config]: https://gh.io/gcm/config#credentialazreposmanagedidentity +[gcm-wif-config]: https://gh.io/gcm/config#credentialazreposworkloadfederation +[gcm-wif-clientid-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationclientid +[gcm-wif-tenantid-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationtenantid +[gcm-wif-audience-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationaudience +[gcm-wif-assertion-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationassertion +[gcm-wif-mi-config]: https://gh.io/gcm/config#credentialazreposworkloadfederationmanagedidentity +[gcm-wif-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF +[gcm-wif-clientid-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_CLIENTID +[gcm-wif-tenantid-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_TENANTID +[gcm-wif-audience-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_AUDIENCE +[gcm-wif-assertion-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_ASSERTION +[gcm-wif-mi-env]: https://gh.io/gcm/env#GCM_AZREPOS_WIF_MANAGEDIDENTITY diff --git a/docs/configuration.md b/docs/configuration.md index ba978ef30..af5d410f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -884,6 +884,138 @@ git config --global credential.azreposManagedIdentity "id://11111111-1111-1111-1 --- +### credential.azreposWorkloadFederation + +Use [Workload Identity Federation][wif] to authenticate with Azure Repos. + +The value specifies the federation scenario to use for obtaining a client +assertion to exchange for an access token. + +You must also set the following companion settings: + +- [credential.azreposWorkloadFederationClientId][credential-azrepos-wif-clientid] +- [credential.azreposWorkloadFederationTenantId][credential-azrepos-wif-tenantid] + +Depending on the scenario, additional settings may be required. + +Value|Description +-|- +`generic`|Use a user-supplied client assertion ([credential.azreposWorkloadFederationAssertion][credential-azrepos-wif-assertion]) +`managedidentity`|Use a [Managed Identity][managed-identity] to obtain the federated token ([credential.azreposWorkloadFederationManagedIdentity][credential-azrepos-wif-managedidentity]) +`githubactions`|Automatically obtain an OIDC token from GitHub Actions + +For more information about workload identity federation, see the +[conceptual documentation][azrepos-wif-doc] and the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederation githubactions +``` + +**Also see: [GCM_AZREPOS_WIF][gcm-azrepos-wif]** + +--- + +### credential.azreposWorkloadFederationClientId + +The client ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationClientId "11111111-1111-1111-1111-111111111111" +``` + +**Also see: [GCM_AZREPOS_WIF_CLIENTID][gcm-azrepos-wif-clientid]** + +--- + +### credential.azreposWorkloadFederationTenantId + +The tenant ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationTenantId "22222222-2222-2222-2222-222222222222" +``` + +**Also see: [GCM_AZREPOS_WIF_TENANTID][gcm-azrepos-wif-tenantid]** + +--- + +### credential.azreposWorkloadFederationAudience + +The audience to use when requesting the federated token for +[Workload Identity Federation][wif] with +[credential.azreposWorkloadFederation][credential-azrepos-wif]. + +Defaults to `api://AzureADTokenExchange`. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationAudience "api://AzureADTokenExchange" +``` + +**Also see: [GCM_AZREPOS_WIF_AUDIENCE][gcm-azrepos-wif-audience]** + +--- + +### credential.azreposWorkloadFederationAssertion + +Specifies the client assertion token to use with the `generic` +[Workload Identity Federation][wif] scenario +([credential.azreposWorkloadFederation][credential-azrepos-wif]). + +This setting is required when `credential.azreposWorkloadFederation` is set to +`generic`. + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationAssertion "eyJhbGci..." +``` + +**Also see: [GCM_AZREPOS_WIF_ASSERTION][gcm-azrepos-wif-assertion]** + +--- + +### credential.azreposWorkloadFederationManagedIdentity + +Specifies the [Managed Identity][managed-identity] to use to obtain a federated +token for the `managedidentity` [Workload Identity Federation][wif] scenario +([credential.azreposWorkloadFederation][credential-azrepos-wif]). + +This setting is required when `credential.azreposWorkloadFederation` is set to +`managedidentity`. + +The value accepts the same formats as +[credential.azreposManagedIdentity](#credentialazreposmanagedidentity). + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Example + +```shell +git config --global credential.azreposWorkloadFederationManagedIdentity system +``` + +**Also see: [GCM_AZREPOS_WIF_MANAGEDIDENTITY][gcm-azrepos-wif-managedidentity]** + +--- + ### credential.azreposServicePrincipal Specify the client and tenant IDs of a [service principal][service-principal] @@ -1048,6 +1180,12 @@ Defaults to disabled. [gcm-autodetect-timeout]: environment.md#GCM_AUTODETECT_TIMEOUT [gcm-azrepos-credentialtype]: environment.md#GCM_AZREPOS_CREDENTIALTYPE [gcm-azrepos-credentialmanagedidentity]: environment.md#GCM_AZREPOS_MANAGEDIDENTITY +[gcm-azrepos-wif]: environment.md#GCM_AZREPOS_WIF +[gcm-azrepos-wif-clientid]: environment.md#GCM_AZREPOS_WIF_CLIENTID +[gcm-azrepos-wif-tenantid]: environment.md#GCM_AZREPOS_WIF_TENANTID +[gcm-azrepos-wif-audience]: environment.md#GCM_AZREPOS_WIF_AUDIENCE +[gcm-azrepos-wif-assertion]: environment.md#GCM_AZREPOS_WIF_ASSERTION +[gcm-azrepos-wif-managedidentity]: environment.md#GCM_AZREPOS_WIF_MANAGEDIDENTITY [gcm-bitbucket-always-refresh-credentials]: environment.md#GCM_BITBUCKET_ALWAYS_REFRESH_CREDENTIALS [gcm-bitbucket-authmodes]: environment.md#GCM_BITBUCKET_AUTHMODES [gcm-credential-cache-options]: environment.md#GCM_CREDENTIAL_CACHE_OPTIONS @@ -1077,6 +1215,7 @@ Defaults to disabled. [autodetect]: autodetect.md [libsecret]: https://wiki.gnome.org/Projects/Libsecret [managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation [provider-migrate]: migration.md#gcm_authority [cache-options]: https://git-scm.com/docs/git-credential-cache#_options [pass]: https://www.passwordstore.org/ @@ -1090,6 +1229,13 @@ Defaults to disabled. [wam]: windows-broker.md [service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals [azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[azrepos-wif-doc]: azrepos-wif.md +[credential-azrepos-wif]: #credentialazreposworkloadfederation +[credential-azrepos-wif-clientid]: #credentialazreposworkloadfederationclientid +[credential-azrepos-wif-tenantid]: #credentialazreposworkloadfederationtenantid +[credential-azrepos-wif-audience]: #credentialazreposworkloadfederationaudience +[credential-azrepos-wif-assertion]: #credentialazreposworkloadfederationassertion +[credential-azrepos-wif-managedidentity]: #credentialazreposworkloadfederationmanagedidentity [credential-azrepos-sp]: #credentialazreposserviceprincipal [credential-azrepos-sp-secret]: #credentialazreposserviceprincipalsecret [credential-azrepos-sp-cert-thumbprint]: #credentialazreposserviceprincipalcertificatethumbprint diff --git a/docs/environment.md b/docs/environment.md index f321caa6c..44a50e4ee 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -991,6 +991,172 @@ export GCM_AZREPOS_MANAGEDIDENTITY="id://11111111-1111-1111-1111-111111111111" --- +### GCM_AZREPOS_WIF + +Use [Workload Identity Federation][wif] to authenticate with Azure Repos. + +The value specifies the federation scenario to use for obtaining a client +assertion to exchange for an access token. + +You must also set the following companion settings: + +- [GCM_AZREPOS_WIF_CLIENTID][gcm-azrepos-wif-clientid] +- [GCM_AZREPOS_WIF_TENANTID][gcm-azrepos-wif-tenantid] + +Depending on the scenario, additional settings may be required. + +Value|Description +-|- +`generic`|Use a user-supplied client assertion ([GCM_AZREPOS_WIF_ASSERTION][gcm-azrepos-wif-assertion]) +`managedidentity`|Use a [Managed Identity][managed-identity] to obtain the federated token ([GCM_AZREPOS_WIF_MANAGEDIDENTITY][gcm-azrepos-wif-managedidentity]) +`githubactions`|Automatically obtain an OIDC token from GitHub Actions + +For more information about workload identity federation, see the +[conceptual documentation][azrepos-wif-doc] and the Azure DevOps +[documentation][azrepos-sp-mid]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF="githubactions" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF="githubactions" +``` + +**Also see: [credential.azreposWorkloadFederation][credential-azrepos-wif]** + +--- + +### GCM_AZREPOS_WIF_CLIENTID + +The client ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_CLIENTID="11111111-1111-1111-1111-111111111111" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_CLIENTID="11111111-1111-1111-1111-111111111111" +``` + +**Also see: [credential.azreposWorkloadFederationClientId][credential-azrepos-wif-clientid]** + +--- + +### GCM_AZREPOS_WIF_TENANTID + +The tenant ID of the app registration / service principal to request an access +token for when using [Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_TENANTID="22222222-2222-2222-2222-222222222222" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_TENANTID="22222222-2222-2222-2222-222222222222" +``` + +**Also see: [credential.azreposWorkloadFederationTenantId][credential-azrepos-wif-tenantid]** + +--- + +### GCM_AZREPOS_WIF_AUDIENCE + +The audience to use when requesting the federated token for +[Workload Identity Federation][wif] with +[GCM_AZREPOS_WIF][gcm-azrepos-wif]. + +Defaults to `api://AzureADTokenExchange`. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_AUDIENCE="api://AzureADTokenExchange" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_AUDIENCE="api://AzureADTokenExchange" +``` + +**Also see: [credential.azreposWorkloadFederationAudience][credential-azrepos-wif-audience]** + +--- + +### GCM_AZREPOS_WIF_ASSERTION + +Specifies the client assertion token to use with the `generic` +[Workload Identity Federation][wif] scenario +([GCM_AZREPOS_WIF][gcm-azrepos-wif]). + +This setting is required when `GCM_AZREPOS_WIF` is set to `generic`. + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_ASSERTION="eyJhbGci..." +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_ASSERTION="eyJhbGci..." +``` + +**Also see: [credential.azreposWorkloadFederationAssertion][credential-azrepos-wif-assertion]** + +--- + +### GCM_AZREPOS_WIF_MANAGEDIDENTITY + +Specifies the [Managed Identity][managed-identity] to use to obtain a federated +token for the `managedidentity` [Workload Identity Federation][wif] scenario +([GCM_AZREPOS_WIF][gcm-azrepos-wif]). + +This setting is required when `GCM_AZREPOS_WIF` is set to `managedidentity`. + +The value accepts the same formats as +[GCM_AZREPOS_MANAGEDIDENTITY](#gcm_azrepos_managedidentity). + +Value|Description +-|- +`system`|System-Assigned Managed Identity +`[guid]`|User-Assigned Managed Identity with the specified client ID +`id://[guid]`|User-Assigned Managed Identity with the specified client ID +`resource://[guid]`|User-Assigned Managed Identity for the associated resource + +#### Windows + +```batch +SET GCM_AZREPOS_WIF_MANAGEDIDENTITY="system" +``` + +#### macOS/Linux + +```bash +export GCM_AZREPOS_WIF_MANAGEDIDENTITY="system" +``` + +**Also see: [credential.azreposWorkloadFederationManagedIdentity][credential-azrepos-wif-managedidentity]** + +--- + ### GCM_AZREPOS_SERVICE_PRINCIPAL Specify the client and tenant IDs of a [service principal][service-principal] @@ -1186,6 +1352,12 @@ Defaults to disabled. [credential-autodetecttimeout]: configuration.md#credentialautodetecttimeout [credential-azrepos-credential-type]: configuration.md#credentialazreposcredentialtype [credential-azrepos-managedidentity]: configuration.md#credentialazreposmanagedidentity +[credential-azrepos-wif]: configuration.md#credentialazreposworkloadfederation +[credential-azrepos-wif-clientid]: configuration.md#credentialazreposworkloadfederationclientid +[credential-azrepos-wif-tenantid]: configuration.md#credentialazreposworkloadfederationtenantid +[credential-azrepos-wif-audience]: configuration.md#credentialazreposworkloadfederationaudience +[credential-azrepos-wif-assertion]: configuration.md#credentialazreposworkloadfederationassertion +[credential-azrepos-wif-managedidentity]: configuration.md#credentialazreposworkloadfederationmanagedidentity [credential-bitbucketauthmodes]: configuration.md#credentialbitbucketAuthModes [credential-cacheoptions]: configuration.md#credentialcacheoptions [credential-credentialstore]: configuration.md#credentialcredentialstore @@ -1224,6 +1396,7 @@ Defaults to disabled. [network-http-proxy]: netconfig.md#http-proxy [libsecret]: https://wiki.gnome.org/Projects/Libsecret [managed-identity]: https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview +[wif]: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation [migration-guide]: migration.md#gcm_authority [passwordstore]: https://www.passwordstore.org/ [trace2-normal-docs]: https://git-scm.com/docs/api-trace2#_the_normal_format_target @@ -1235,6 +1408,13 @@ Defaults to disabled. [windows-broker]: windows-broker.md [service-principal]: https://docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals [azrepos-sp-mid]: https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity +[azrepos-wif-doc]: azrepos-wif.md +[gcm-azrepos-wif]: #gcm_azrepos_wif +[gcm-azrepos-wif-clientid]: #gcm_azrepos_wif_clientid +[gcm-azrepos-wif-tenantid]: #gcm_azrepos_wif_tenantid +[gcm-azrepos-wif-audience]: #gcm_azrepos_wif_audience +[gcm-azrepos-wif-assertion]: #gcm_azrepos_wif_assertion +[gcm-azrepos-wif-managedidentity]: #gcm_azrepos_wif_managedidentity [gcm-azrepos-sp]: #gcm_azrepos_service_principal [gcm-azrepos-sp-secret]: #gcm_azrepos_sp_secret [gcm-azrepos-sp-cert-thumbprint]: #gcm_azrepos_sp_cert_thumbprint From 42bf6e96f6ecec4f7cd61416ff6c26838012c019 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 11:54:06 +0100 Subject: [PATCH 36/62] github/gitlab: use correct param order When using a custom credential UI for either GitHub or GitLab the commands are mixing up the optional URL and user name args. This is because the positional args of the `ExecuteAsync` methods was not the same as the `SetHandler`. Swap the args to fix this. Signed-off-by: Matthew John Cheetham --- src/shared/GitHub/UI/Commands/CredentialsCommand.cs | 2 +- src/shared/GitLab/UI/Commands/CredentialsCommand.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs index f14b3cb3e..45c6cfd7f 100644 --- a/src/shared/GitHub/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitHub/UI/Commands/CredentialsCommand.cs @@ -38,7 +38,7 @@ protected CredentialsCommand(ICommandContext context) this.SetHandler(ExecuteAsync, url, userName, basic, browser, device, pat, all); } - private async Task ExecuteAsync(string userName, string enterpriseUrl, + private async Task ExecuteAsync(string enterpriseUrl, string userName, bool basic, bool browser, bool device, bool pat, bool all) { var viewModel = new CredentialsViewModel(Context.SessionManager, Context.ProcessManager) diff --git a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs index 1c1995a8d..02a0f7818 100644 --- a/src/shared/GitLab/UI/Commands/CredentialsCommand.cs +++ b/src/shared/GitLab/UI/Commands/CredentialsCommand.cs @@ -35,7 +35,7 @@ protected CredentialsCommand(ICommandContext context) this.SetHandler(ExecuteAsync, url, userName, basic, browser, pat, all); } - private async Task ExecuteAsync(string userName, string url, bool basic, bool browser, bool pat, bool all) + private async Task ExecuteAsync(string url, string userName, bool basic, bool browser, bool pat, bool all) { var viewModel = new CredentialsViewModel(Context.SessionManager) { From 51a237938fd41fe1663a05a6df32802df503c7c4 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 11:56:26 +0100 Subject: [PATCH 37/62] streamextensions: fix a bug in multi-var reset handling The multi-dictionary writer normalises value lists to honor reset semantics (empty values clear prior entries). However, when only one normalised value remains, it writes the first element from the original list instead of the normalised list. If the list contains an empty reset marker followed by a single valid value, the output will incorrectly emit the pre-reset value (or an empty string), violating the protocol and potentially leaking stale data that should have been cleared. Fix the issue and extend the unit tests to cover this shape. Signed-off-by: Matthew John Cheetham --- src/shared/Core.Tests/StreamExtensionsTests.cs | 5 +++-- src/shared/Core/StreamExtensions.cs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/shared/Core.Tests/StreamExtensionsTests.cs b/src/shared/Core.Tests/StreamExtensionsTests.cs index 09153ad26..b72874ba9 100644 --- a/src/shared/Core.Tests/StreamExtensionsTests.cs +++ b/src/shared/Core.Tests/StreamExtensionsTests.cs @@ -381,12 +381,13 @@ public void StreamExtensions_WriteDictionary_MultiEntriesWithEmpty_WritesKVPList { ["a"] = new[] {"1", "2", "", "3", "4"}, ["b"] = new[] {"5"}, - ["c"] = new[] {"6", "7", ""} + ["c"] = new[] {"6", "7", ""}, + ["d"] = new[] {"8", "", "9"} }; string output = WriteStringStream(input, StreamExtensions.WriteDictionary, newLine: LF); - Assert.Equal("a[]=3\na[]=4\nb=5\n\n", output); + Assert.Equal("a[]=3\na[]=4\nb=5\nd=9\n\n", output); } #endregion diff --git a/src/shared/Core/StreamExtensions.cs b/src/shared/Core/StreamExtensions.cs index 7ff338f5a..beb85699b 100644 --- a/src/shared/Core/StreamExtensions.cs +++ b/src/shared/Core/StreamExtensions.cs @@ -179,7 +179,7 @@ public static void WriteDictionary(this TextWriter writer, IDictionary Date: Tue, 31 Mar 2026 11:59:31 +0100 Subject: [PATCH 38/62] github: handle empty domain or enterprise hints If the WWW-Authenticate header from GitHub is missing a domain or enterprise hint we'd be hitting a null-reference exception when calculating a hash code for the `GitHubAuthChallenge`. Fix this. Signed-off-by: Matthew John Cheetham --- src/shared/GitHub/GitHubAuthChallenge.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/shared/GitHub/GitHubAuthChallenge.cs b/src/shared/GitHub/GitHubAuthChallenge.cs index de3afbdbd..1b33330c9 100644 --- a/src/shared/GitHub/GitHubAuthChallenge.cs +++ b/src/shared/GitHub/GitHubAuthChallenge.cs @@ -107,7 +107,15 @@ public override bool Equals(object obj) public override int GetHashCode() { - return Domain.GetHashCode() * 1019 ^ - Enterprise.GetHashCode() * 337; + int domainHash = Domain is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(Domain); + + int enterpriseHash = Enterprise is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(Enterprise); + + return (domainHash * 1019) ^ + (enterpriseHash * 337); } } From 453ae23fe265b2099f1705481001f6d7d7834ed4 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:00:51 +0100 Subject: [PATCH 39/62] github: do not filter accounts outside of dotcom Outside of GitHub.com we should not filter accounts. The `FilterAccounts` method was detecting and logging this, but didn't actually stop filtering! Signed-off-by: Matthew John Cheetham --- src/shared/GitHub/GitHubHostProvider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 06afd9592..07607dd4e 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -198,6 +198,7 @@ private bool FilterAccounts(Uri remoteUri, IEnumerable wwwAuth, ref ILis if (!IsGitHubDotCom(remoteUri)) { _context.Trace.WriteLine("No account filtering outside of GitHub.com."); + return false; } // Allow the user to disable account filtering until this feature stabilises. From 817f5e3609ac3a51718e485f5e506b3e77f3d745 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:02:08 +0100 Subject: [PATCH 40/62] diagnose: fix network diag to await HTTP requests The network diagnostic failed to correctly await the test HTTP requests, and also did not return and await a `Task` (to capture exceptions). Signed-off-by: Matthew John Cheetham --- .../Core.Tests/Commands/DiagnoseCommandTests.cs | 13 +++++++------ src/shared/Core/Diagnostics/NetworkingDiagnostic.cs | 4 ++-- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs index 0118e9d85..42f5cedc7 100644 --- a/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs +++ b/src/shared/Core.Tests/Commands/DiagnoseCommandTests.cs @@ -2,6 +2,7 @@ using System.Net.Http; using System.Security.AccessControl; using System.Text; +using System.Threading.Tasks; using GitCredentialManager.Diagnostics; using GitCredentialManager.Tests.Objects; using Xunit; @@ -11,7 +12,7 @@ namespace Core.Tests.Commands; public class DiagnoseCommandTests { [Fact] - public void NetworkingDiagnostic_SendHttpRequest_Primary_OK() + public async Task NetworkingDiagnostic_SendHttpRequest_Primary_OK() { var primaryUriString = "http://example.com"; var sb = new StringBuilder(); @@ -24,14 +25,14 @@ public void NetworkingDiagnostic_SendHttpRequest_Primary_OK() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); Assert.Contains(expected, sb.ToString()); } [Fact] - public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() + public async Task NetworkingDiagnostic_SendHttpRequest_Backup_OK() { var primaryUriString = "http://example.com"; var backupUriString = "http://httpforever.com"; @@ -48,7 +49,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1); @@ -56,7 +57,7 @@ public void NetworkingDiagnostic_SendHttpRequest_Backup_OK() } [Fact] - public void NetworkingDiagnostic_SendHttpRequest_No_Network() + public async Task NetworkingDiagnostic_SendHttpRequest_No_Network() { var primaryUriString = "http://example.com"; var backupUriString = "http://httpforever.com"; @@ -73,7 +74,7 @@ public void NetworkingDiagnostic_SendHttpRequest_No_Network() httpHandler.Setup(HttpMethod.Head, primaryUri, httpResponse); httpHandler.Setup(HttpMethod.Head, backupUri, httpResponse); - networkingDiagnostic.SendHttpRequest(sb, new HttpClient(httpHandler)); + await networkingDiagnostic.SendHttpRequestAsync(sb, new HttpClient(httpHandler)); httpHandler.AssertRequest(HttpMethod.Head, primaryUri, expectedNumberOfCalls: 1); httpHandler.AssertRequest(HttpMethod.Head, backupUri, expectedNumberOfCalls: 1); diff --git a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs index 50ab5b4da..c49104ea8 100644 --- a/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs +++ b/src/shared/Core/Diagnostics/NetworkingDiagnostic.cs @@ -29,7 +29,7 @@ protected override async Task RunInternalAsync(StringBuilder log, IList RunInternalAsync(StringBuilder log, IList { TestHttpUri, TestHttpUriFallback }) { From 774af8ea9ef83fbbee38e806c436425c412a5ae8 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:03:24 +0100 Subject: [PATCH 41/62] macos: add die function to notarize.sh script Add the missing die function. Signed-off-by: Matthew John Cheetham --- src/osx/Installer.Mac/notarize.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/osx/Installer.Mac/notarize.sh b/src/osx/Installer.Mac/notarize.sh index 9315d688a..f3aa55d00 100755 --- a/src/osx/Installer.Mac/notarize.sh +++ b/src/osx/Installer.Mac/notarize.sh @@ -1,4 +1,8 @@ #!/bin/bash +die () { + echo "$*" >&2 + exit 1 +} for i in "$@" do From 66c6ef06d1c1fcfefa9e42076f65bdbee04d97d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:32:43 +0000 Subject: [PATCH 42/62] build(deps): bump actions/github-script from 8 to 9 Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v8...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/maintainer-absence.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/maintainer-absence.yml b/.github/workflows/maintainer-absence.yml index 20e6694e7..3de79e6a1 100644 --- a/.github/workflows/maintainer-absence.yml +++ b/.github/workflows/maintainer-absence.yml @@ -18,7 +18,7 @@ jobs: name: create-issue runs-on: ubuntu-latest steps: - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 with: script: | const startDate = new Date('${{ github.event.inputs.startDate }}'); From b47259ca54fedfb82df61a0d550037801cec60f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:21:23 +0000 Subject: [PATCH 43/62] Bump Tmds.DBus.Protocol from 0.16.0 to 0.21.3 --- updated-dependencies: - dependency-name: Tmds.DBus.Protocol dependency-version: 0.21.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 1 + src/shared/Core/Core.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/Directory.Packages.props b/Directory.Packages.props index a836e1bca..d1e002d85 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + diff --git a/src/shared/Core/Core.csproj b/src/shared/Core/Core.csproj index cdfd08deb..d316df992 100644 --- a/src/shared/Core/Core.csproj +++ b/src/shared/Core/Core.csproj @@ -32,6 +32,7 @@ + From 9452c8a1f04ee81d8f9849042d5d7af99c5fb831 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Wed, 22 Apr 2026 11:28:07 +0100 Subject: [PATCH 44/62] azure-pipelines: specify pool.hostArchitecture correctly We need to specify 'arm64' for the hostArchitecture in 1ES pipeline templates so the correct tasks are installed/used on ARM machines. Defaults to 'amd64' (for x86-based hosts). Signed-off-by: Matthew John Cheetham --- .azure-pipelines/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index 68808aa67..f020c676f 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -35,18 +35,21 @@ parameters: jobName: 'Windows (x86)' runtime: win-x86 pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: win-x86_64-ado1es os: windows - id: windows_x64 jobName: 'Windows (x64)' runtime: win-x64 pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: win-x86_64-ado1es os: windows - id: windows_arm64 jobName: 'Windows (ARM64)' runtime: win-arm64 pool: GitClientPME-1ESHostedPool-arm64-pc + poolArch: arm64 image: win-arm64-ado1es os: windows @@ -73,12 +76,14 @@ parameters: jobName: 'Linux (x64)' runtime: linux-x64 pool: GitClientPME-1ESHostedPool-intel-pc + poolArch: amd64 image: ubuntu-x86_64-ado1es os: linux - id: linux_arm64 jobName: 'Linux (ARM64)' runtime: linux-arm64 pool: GitClientPME-1ESHostedPool-arm64-pc + poolArch: arm64 image: ubuntu-arm64-ado1es os: linux @@ -121,6 +126,7 @@ extends: name: ${{ dim.pool }} image: ${{ dim.image }} os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} templateContext: outputs: - output: pipelineArtifact @@ -551,6 +557,7 @@ extends: name: ${{ dim.pool }} image: ${{ dim.image }} os: ${{ dim.os }} + hostArchitecture: ${{ dim.poolArch }} templateContext: outputs: - output: pipelineArtifact From 265c4ac311b41c63de82b7fe58a0b8a281df0f8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:40:59 +0000 Subject: [PATCH 45/62] Initial plan From 95502ae25ff9b03460fdf81436b6ae92a2b580d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:53:13 +0000 Subject: [PATCH 46/62] Add dark theme support to OAuth authentication response pages Agent-Logs-Url: https://github.com/git-ecosystem/git-credential-manager/sessions/00d6d223-97c2-4e72-844b-23a7b2b14b63 Co-authored-by: mjcheetham <5658207+mjcheetham@users.noreply.github.com> --- .../BitbucketResources.resx | 19 ++++++++++++- .../OAuth/OAuth2SystemWebBrowser.cs | 8 ++++-- src/shared/GitHub/GitHubResources.resx | 28 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx index d7e6058e8..e0f247248 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx +++ b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx @@ -22,10 +22,19 @@ + Bitbucket Authentication +
@@ -53,11 +62,19 @@ + Bitbucket Authentication - +
diff --git a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs index 34d6cfbe7..05843f9df 100644 --- a/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs +++ b/src/shared/Core/Authentication/OAuth/OAuth2SystemWebBrowser.cs @@ -10,12 +10,16 @@ namespace GitCredentialManager.Authentication.OAuth public class OAuth2WebBrowserOptions { internal const string DefaultSuccessHtml = @" - + + Authentication successful

Authentication successful

You can now close this page.

"; internal const string DefaultFailureHtmlFormat = @" - + + Authentication failed

Authentication failed

Error:
{0}
diff --git a/src/shared/GitHub/GitHubResources.resx b/src/shared/GitHub/GitHubResources.resx index a5348d617..3972d4779 100644 --- a/src/shared/GitHub/GitHubResources.resx +++ b/src/shared/GitHub/GitHubResources.resx @@ -20,6 +20,7 @@ + Git Credential Manager - Authentication Succeeded
@@ -75,6 +89,7 @@ p { + Git Credential Manager - Authentication Failed
From 50cdad490eef4afe5c5fa337f9338f42299cae34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:18:51 +0000 Subject: [PATCH 47/62] Fix Bitbucket dark mode footer-logo background Agent-Logs-Url: https://github.com/git-ecosystem/git-credential-manager/sessions/132c1df8-8b2a-4889-829a-816416100c64 Co-authored-by: mjcheetham <5658207+mjcheetham@users.noreply.github.com> --- src/shared/Atlassian.Bitbucket/BitbucketResources.resx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx index e0f247248..165edd0c8 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketResources.resx +++ b/src/shared/Atlassian.Bitbucket/BitbucketResources.resx @@ -33,6 +33,7 @@ .aui-page-panel, .aui-page-panel-inner, .aui-page-panel-content { background: #161B22 !important; color: #C9D1D9 !important; border-color: #30363D !important; } h1, h2, h3, h4, h5, h6, p { color: #C9D1D9 !important; } a { color: #58A6FF !important; } + #footer, #footer-logo { background: #0D1117 !important; color: #C9D1D9 !important; } } @@ -73,6 +74,7 @@ .aui-page-panel, .aui-page-panel-inner, .aui-page-panel-content {{ background: #161B22 !important; color: #C9D1D9 !important; border-color: #30363D !important; }} h1, h2, h3, h4, h5, h6, p, dt, dd {{ color: #C9D1D9 !important; }} a {{ color: #58A6FF !important; }} + #footer, #footer-logo {{ background: #0D1117 !important; color: #C9D1D9 !important; }} }} From bb004169d968e0c332403dbeb2aa6185c0eaea18 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Thu, 23 Apr 2026 13:16:16 +0100 Subject: [PATCH 48/62] http: fix SYSLIB0057 warning for X509Certificate2Collection.Import Use ImportFromPemFile on modern .NET to resolve the SYSLIB0057 deprecation warning, while keeping the original Import call on .NET Framework where the new API is not available. Signed-off-by: Matthew John Cheetham --- src/shared/Core/HttpClientFactory.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/shared/Core/HttpClientFactory.cs b/src/shared/Core/HttpClientFactory.cs index c48e277e5..d66fad39f 100644 --- a/src/shared/Core/HttpClientFactory.cs +++ b/src/shared/Core/HttpClientFactory.cs @@ -130,7 +130,11 @@ public HttpClient CreateClient() // Import the custom certs X509Certificate2Collection certBundle = new X509Certificate2Collection(); +#if NETFRAMEWORK certBundle.Import(certBundlePath); +#else + certBundle.ImportFromPemFile(certBundlePath); +#endif try { From 5d850554b11fad55fa54e4df333d610f33c172af Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:04:35 +0100 Subject: [PATCH 49/62] git: drain stderr on IsInsideRepository When suppressStreams is true, Git's stderr is redirected to a pipe but then we only read stdout before waiting for exit. If Git writes lots to stderr (for example when tracing is enabled), the stderr pipe can fill, causing the Git process to block and GCM to hang while checking repository state. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Git.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/shared/Core/Git.cs b/src/shared/Core/Git.cs index 0c58e0159..cef57e47d 100644 --- a/src/shared/Core/Git.cs +++ b/src/shared/Core/Git.cs @@ -146,6 +146,15 @@ private string GetCurrentRepositoryInternal(bool suppressStreams) } git.Start(Trace2ProcessClass.Git); + + // Drain and throw away stderr asynchronously to avoid a deadlock + // if the child process fills the stderr pipe buffer. + if (suppressStreams) + { + git.Process.ErrorDataReceived += (_, _) => { }; + git.Process.BeginErrorReadLine(); + } + string data = git.StandardOutput.ReadToEnd(); git.WaitForExit(); From 86fd16e30e061f725a0b8f6eee54b559328c62bc Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:07:24 +0100 Subject: [PATCH 50/62] windows: fix layout.ps1 if symboloutput is not set If SymbolOutput is not set then there's a bug whereby we try and trim the end '/' and '\' characters on a null value. Guard against this. Signed-off-by: Matthew John Cheetham --- src/windows/Installer.Windows/layout.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/windows/Installer.Windows/layout.ps1 b/src/windows/Installer.Windows/layout.ps1 index 3b1624896..53646764a 100644 --- a/src/windows/Installer.Windows/layout.ps1 +++ b/src/windows/Installer.Windows/layout.ps1 @@ -3,7 +3,10 @@ param ([Parameter(Mandatory)] $Configuration, [Parameter(Mandatory)] $Output, $R # Trim trailing slashes from output paths $Output = $Output.TrimEnd('\','/') -$SymbolOutput = $SymbolOutput.TrimEnd('\','/') + +if ($SymbolOutput) { + $SymbolOutput = $SymbolOutput.TrimEnd('\','/') +} Write-Output "Output: $Output" From 2226933756ed353903fd23021059af1f9478950f Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:10:31 +0100 Subject: [PATCH 51/62] oauth: pass cancellation token to in-proc device code UI In the OAuth device-code flow, the in-proc UI path the calling logic creates a `CancellationTokenSource` and later calls `Cancel()` on that CTS to close the dialog once the token is obtained (or the user cancels). However, `ShowDeviceCodeViaUiAsync` disregards the provided cancellation token and passes `CancellationToken.None` into `AvaloniaUi.ShowViewAsync`. Pass the cancellation token correctly. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Authentication/OAuthAuthentication.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs index a8de4ecb6..792ba40ec 100644 --- a/src/shared/Core/Authentication/OAuthAuthentication.cs +++ b/src/shared/Core/Authentication/OAuthAuthentication.cs @@ -247,7 +247,7 @@ private Task ShowDeviceCodeViaUiAsync(OAuth2DeviceCodeResult dcr, CancellationTo VerificationUrl = dcr.VerificationUri.ToString(), }; - return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), CancellationToken.None); + return AvaloniaUi.ShowViewAsync(viewModel, GetParentWindowHandle(), ct); } private Task ShowDeviceCodeViaHelperAsync( From 91362ebeb984fa6ed1066f099d45c7127c33f518 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:19:24 +0100 Subject: [PATCH 52/62] trace2: fix main thread identification The `Thread::ManagedTheadId` always starts with the entry thread as `1` and not `0`. https://github.com/dotnet/runtime/blob/790e8a525a0f76b8ad755c12e95b7f8770195d67/src/coreclr/nativeaot/System.Private.CoreLib/src/System/Threading/ManagedThreadId.cs#L181 Fix this in Trace2. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Trace2.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Trace2.cs b/src/shared/Core/Trace2.cs index 535812ea8..e7f048ca2 100644 --- a/src/shared/Core/Trace2.cs +++ b/src/shared/Core/Trace2.cs @@ -640,7 +640,7 @@ private void WriteMessage(Trace2Message message) private static string BuildThreadName() { // If this is the entry thread, call it "main", per Trace2 convention - if (Thread.CurrentThread.ManagedThreadId == 0) + if (Thread.CurrentThread.ManagedThreadId == 1) { return "main"; } From 143ce4289001d13e2133df9b7e40133ccfe278ab Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:30:23 +0100 Subject: [PATCH 53/62] trace2: fix crash in perf format for large elapsed times BuildTimeSpan assumes an 11-character span and adjusts padding when the formatted elapsed time overflows. For values with 5+ digits before the decimal (>= 10000 seconds), the size difference exceeds the available padding budget, driving BeginPadding below zero. This causes `new string(' ', BeginPadding)` to throw an ArgumentOutOfRangeException. Since Trace2FileWriter does not catch exceptions, this crashes the credential helper when TRACE2 performance output is enabled. Fix the overflow check from `==` to `>=` so that values exceeding the full span (data + padding) zero out all padding rather than producing a negative value. Signed-off-by: Matthew John Cheetham --- src/shared/Core.Tests/Trace2MessageTests.cs | 2 ++ src/shared/Core/Trace2Message.cs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/Core.Tests/Trace2MessageTests.cs b/src/shared/Core.Tests/Trace2MessageTests.cs index 7e29a641f..82c1249ca 100644 --- a/src/shared/Core.Tests/Trace2MessageTests.cs +++ b/src/shared/Core.Tests/Trace2MessageTests.cs @@ -12,6 +12,8 @@ public class Trace2MessageTests [InlineData(26.316083, " 26.316083 ")] [InlineData(100.316083, "100.316083 ")] [InlineData(1000.316083, "1000.316083")] + [InlineData(10000.316083, "10000.316083")] + [InlineData(100000.31608, "100000.316080")] public void BuildTimeSpan_Match_Returns_Expected_String(double input, string expected) { var actual = Trace2Message.BuildTimeSpan(input); diff --git a/src/shared/Core/Trace2Message.cs b/src/shared/Core/Trace2Message.cs index 14327031f..78eb05a20 100644 --- a/src/shared/Core/Trace2Message.cs +++ b/src/shared/Core/Trace2Message.cs @@ -151,7 +151,7 @@ private static string BuildSpan(PerformanceFormatSpan component, string data) if (double.TryParse(data, out _)) { // Remove all padding for values that take up the entire span - if (Math.Abs(sizeDifference) == paddingTotal) + if (Math.Abs(sizeDifference) >= paddingTotal) { component.BeginPadding = 0; component.EndPadding = 0; From d637224b1f8058075c452fe62c529fd1b9140938 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:33:26 +0100 Subject: [PATCH 54/62] http: use correct http.sslAutoClientCert setting name We had been incorrectly looking for the `sslAutoClientCert` Git config option under the `credential` section, rather than `http`. Note: this is a Git for Windows only option. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Settings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs index af3dcf99c..480db7ea5 100644 --- a/src/shared/Core/Settings.cs +++ b/src/shared/Core/Settings.cs @@ -659,7 +659,7 @@ public bool IsCertificateVerificationEnabled } public bool AutomaticallyUseClientCertificates => - TryGetSetting(null, KnownGitCfg.Credential.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false); + TryGetSetting(null, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslAutoClientCert, out string value) && value.ToBooleanyOrDefault(false); public string CustomCertificateBundlePath => TryGetPathSetting(KnownEnvars.GitSslCaInfo, KnownGitCfg.Http.SectionName, KnownGitCfg.Http.SslCaInfo, out string value) ? value : null; From 782aadae7c653c544d542c78d67c65a91007b238 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 12:39:15 +0100 Subject: [PATCH 55/62] trace2: fix incomplete disposal of writers on cleanup ReleaseManagedResources iterates forward by index while removing elements from the same list. Each removal shifts remaining elements left, but the loop increments i, causing the next element to be skipped. As a result, only about half of the writers are disposed and removed, leaving file handles or buffers open. Fix by iterating in reverse so that removals do not shift any unvisited indices, and use RemoveAt(i) to avoid a redundant linear search. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Trace2.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/shared/Core/Trace2.cs b/src/shared/Core/Trace2.cs index e7f048ca2..de6ca5822 100644 --- a/src/shared/Core/Trace2.cs +++ b/src/shared/Core/Trace2.cs @@ -460,11 +460,11 @@ protected override void ReleaseManagedResources() { try { - for (int i = 0; i < _writers.Count; i += 1) + for (int i = _writers.Count - 1; i >= 0; i--) { - using (var writer = _writers[i]) + using (_writers[i]) { - _writers.Remove(writer); + _writers.RemoveAt(i); } } } From a79ad388db0f33f40a93db2550da0417fb9b3f05 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 13:32:27 +0100 Subject: [PATCH 56/62] git: fix crash when reading stderr from non-redirected processes ProcessManager.CreateProcess sets RedirectStandardError=false for all processes to avoid TRACE2 deadlocks. However, GetRemotes and CreateGitException unconditionally read StandardError, which throws InvalidOperationException when stderr is not redirected. Fix GetRemotes by explicitly redirecting stderr before starting the process, since it needs to check for 'not a git repository' errors. Guard CreateGitException defensively, as it is called from various contexts where stderr may or may not be redirected. Signed-off-by: Matthew John Cheetham --- src/shared/Core/Git.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/shared/Core/Git.cs b/src/shared/Core/Git.cs index cef57e47d..82588357c 100644 --- a/src/shared/Core/Git.cs +++ b/src/shared/Core/Git.cs @@ -176,6 +176,8 @@ public IEnumerable GetRemotes() { using (var git = CreateProcess("remote -v show")) { + // Redirect stderr so we can check for 'not a git repository' errors + git.StartInfo.RedirectStandardError = true; git.Start(Trace2ProcessClass.Git); // To avoid deadlocks, always read the output stream first and then wait // TODO: don't read in all the data at once; stream it @@ -276,7 +278,9 @@ public async Task> InvokeHelperAsync(string args, ID public static GitException CreateGitException(ChildProcess git, string message, ITrace2 trace2 = null) { - var gitMessage = git.StandardError.ReadToEnd(); + var gitMessage = git.StartInfo.RedirectStandardError + ? git.StandardError.ReadToEnd() + : null; if (trace2 != null) throw new Trace2GitException(trace2, message, git.ExitCode, gitMessage); From 0c1fe0d983b6869775c631b1b513cadd2fe29a70 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 14:01:02 +0100 Subject: [PATCH 57/62] environment: check execute permission in TryLocateExecutable In f1a1ae5 (environment: manually scan $PATH on POSIX systems, 2022-05-31) the `which`-based lookup was replaced with a manual PATH scan that only checks FileExists, without verifying execute permissions. Unlike `which`, this means a non-executable file earlier in PATH can shadow a valid executable, causing process creation to fail when GCM later tries to run the located path. Add an IsExecutable check that verifies at least one execute bit is set on POSIX systems, matching the behaviour of `which`. On Windows, any existing file is considered executable. Guard the POSIX-specific File.GetUnixFileMode call with #if !NETFRAMEWORK for net472 compatibility. https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03 > PATH > [..] The list shall be searched from beginning to end, applying the > filename to each prefix, until an executable file with the specified > name and appropriate execution permissions is found Signed-off-by: Matthew John Cheetham --- src/shared/Core.Tests/EnvironmentTests.cs | 29 +++++++++++++++++++ src/shared/Core/EnvironmentBase.cs | 3 +- src/shared/Core/FileSystem.cs | 25 ++++++++++++++++ .../Objects/TestFileSystem.cs | 27 +++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/shared/Core.Tests/EnvironmentTests.cs b/src/shared/Core.Tests/EnvironmentTests.cs index d9b7cb67c..40c685c2a 100644 --- a/src/shared/Core.Tests/EnvironmentTests.cs +++ b/src/shared/Core.Tests/EnvironmentTests.cs @@ -94,6 +94,7 @@ public void PosixEnvironment_TryLocateExecutable_Exists_ReturnTrueAndPath() [expectedPath] = Array.Empty(), } }; + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); @@ -116,6 +117,32 @@ public void PosixEnvironment_TryLocateExecutable_ExistsMultiple_ReturnTrueAndFir ["/bin/foo"] = Array.Empty(), } }; + fs.SetExecutable(expectedPath); + fs.SetExecutable("/usr/local/bin/foo"); + fs.SetExecutable("/bin/foo"); + var envars = new Dictionary {["PATH"] = PosixPathVar}; + var env = new PosixEnvironment(fs, envars); + + bool actualResult = env.TryLocateExecutable(PosixExecName, out string actualPath); + + Assert.True(actualResult); + Assert.Equal(expectedPath, actualPath); + } + + [PosixFact] + public void PosixEnvironment_TryLocateExecutable_NotExecutable_SkipsToNextMatch() + { + string nonExecPath = "/home/john.doe/bin/foo"; + string expectedPath = "/usr/local/bin/foo"; + var fs = new TestFileSystem + { + Files = new Dictionary + { + [nonExecPath] = Array.Empty(), + [expectedPath] = Array.Empty(), + } + }; + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); @@ -142,6 +169,8 @@ public void MacOSEnvironment_TryLocateExecutable_Paths_Are_Ignored() [expectedPath] = Array.Empty(), } }; + fs.SetExecutable(pathsToIgnore.FirstOrDefault()); + fs.SetExecutable(expectedPath); var envars = new Dictionary {["PATH"] = PosixPathVar}; var env = new PosixEnvironment(fs, envars); diff --git a/src/shared/Core/EnvironmentBase.cs b/src/shared/Core/EnvironmentBase.cs index 6a3967193..39ed9dd03 100644 --- a/src/shared/Core/EnvironmentBase.cs +++ b/src/shared/Core/EnvironmentBase.cs @@ -138,7 +138,8 @@ internal virtual bool TryLocateExecutable(string program, ICollection pa { string candidatePath = Path.Combine(basePath, program); if (FileSystem.FileExists(candidatePath) && (pathsToIgnore is null || - !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase))) + !pathsToIgnore.Contains(candidatePath, StringComparer.OrdinalIgnoreCase)) + && FileSystem.FileIsExecutable(candidatePath)) { path = candidatePath; return true; diff --git a/src/shared/Core/FileSystem.cs b/src/shared/Core/FileSystem.cs index aeacfd51d..c23f0faa1 100644 --- a/src/shared/Core/FileSystem.cs +++ b/src/shared/Core/FileSystem.cs @@ -34,6 +34,14 @@ public interface IFileSystem /// True if a file exists, false otherwise. bool FileExists(string path); + /// + /// Check if a file has execute permissions. + /// On Windows this always returns true. On POSIX it checks for any execute bit. + /// + /// Full path to file to test. + /// True if the file is executable, false otherwise. + bool FileIsExecutable(string path); + /// /// Check if a directory exists at the specified path. /// @@ -122,6 +130,23 @@ public abstract class FileSystem : IFileSystem public bool FileExists(string path) => File.Exists(path); +#if NETFRAMEWORK + public bool FileIsExecutable(string path) => true; +#else + public bool FileIsExecutable(string path) + { + if (!PlatformUtils.IsPosix()) + return true; + +#pragma warning disable CA1416 // Platform guard via PlatformUtils.IsPosix() + var mode = File.GetUnixFileMode(path); + return (mode & (UnixFileMode.UserExecute | + UnixFileMode.GroupExecute | + UnixFileMode.OtherExecute)) != 0; +#pragma warning restore CA1416 + } +#endif + public bool DirectoryExists(string path) => Directory.Exists(path); public string GetCurrentDirectory() => Directory.GetCurrentDirectory(); diff --git a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs index 11dff8f1f..57a75f2b8 100644 --- a/src/shared/TestInfrastructure/Objects/TestFileSystem.cs +++ b/src/shared/TestInfrastructure/Objects/TestFileSystem.cs @@ -11,6 +11,7 @@ public class TestFileSystem : IFileSystem public string UserHomePath { get; set; } public string UserDataDirectoryPath { get; set; } public IDictionary Files { get; set; } = new Dictionary(); + public ISet ExecutableFiles { get; } = new HashSet(); public ISet Directories { get; set; } = new HashSet(); public string CurrentDirectory { get; set; } = Path.GetTempPath(); public bool IsCaseSensitive { get; set; } = false; @@ -36,6 +37,18 @@ bool IFileSystem.FileExists(string path) return Files.ContainsKey(path); } + bool IFileSystem.FileIsExecutable(string path) + { + if (!Files.ContainsKey(path)) + throw new FileNotFoundException("File not found", path); + + // On Windows, all files are considered executable. + if (!PlatformUtils.IsPosix()) + return true; + + return ExecutableFiles.Contains(path); + } + bool IFileSystem.DirectoryExists(string path) { return Directories.Contains(TrimSlash(path)); @@ -130,6 +143,20 @@ string[] IFileSystem.ReadAllLines(string path) #endregion + /// + /// Mark a test file as executable. File must exist in already. + /// + public void SetExecutable(string path, bool isExecutable = true) + { + if (!Files.ContainsKey(path)) + throw new FileNotFoundException("File not found", path); + + if (isExecutable) + ExecutableFiles.Add(path); + else + ExecutableFiles.Remove(path); + } + /// /// Trim trailing slashes from a path. /// From fa7b37418c11de2911bbc7005b293466c02705ca Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 31 Mar 2026 14:04:58 +0100 Subject: [PATCH 58/62] windows: fix en-dash characters in installer Exec command The powershell.exe invocation in the Installer.Windows.csproj Exec task uses Unicode en dash characters (U+2013) instead of ASCII hyphens for the -NonInteractive and -ExecutionPolicy parameters. Windows PowerShell 5.1 does not recognise en dashes as parameter prefixes, so these flags are not applied correctly, which can cause the layout step to fail or run with an unexpected execution policy. Replace the en dash characters with ASCII hyphens. Signed-off-by: Matthew John Cheetham --- src/windows/Installer.Windows/Installer.Windows.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/windows/Installer.Windows/Installer.Windows.csproj b/src/windows/Installer.Windows/Installer.Windows.csproj index ec678fe5f..36f5cd5f7 100644 --- a/src/windows/Installer.Windows/Installer.Windows.csproj +++ b/src/windows/Installer.Windows/Installer.Windows.csproj @@ -44,7 +44,7 @@ From d8846d1e8fcad3c689c7a56a231916c51a4c324a Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 10:58:30 +0100 Subject: [PATCH 59/62] VERSION: bump to 2.8.0 Signed-off-by: Matthew John Cheetham --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 364608644..0ab902011 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.7.3.0 +2.8.0.0 From d7778f9091a8124f07e4a5fd34d6d807446dbdf3 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 11:12:41 +0100 Subject: [PATCH 60/62] install: update install-from-source to use .NET 10.0 The project now targets net10.0 but the install-from-source script still referenced .NET SDK 8.0. Update all references to 10.0 and fix the version parsing to use field-based extraction instead of fixed-width character slicing, which broke for two-digit major versions. Signed-off-by: Matthew John Cheetham --- docs/install.md | 2 +- src/linux/Packaging.Linux/install-from-source.sh | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/install.md b/docs/install.md index 9fa7da4ac..86ead9557 100644 --- a/docs/install.md +++ b/docs/install.md @@ -210,7 +210,7 @@ the preferred install method for Linux because you can use it to install on any distribution][dotnet-supported-distributions]. You can also use this method on macOS if you so choose. -**Note:** Make sure you have installed [version 8.0 of the .NET +**Note:** Make sure you have installed [version 10.0 of the .NET SDK][dotnet-install] before attempting to run the following `dotnet tool` commands. After installing, you will also need to follow the output instructions to add the tools directory to your `PATH`. diff --git a/src/linux/Packaging.Linux/install-from-source.sh b/src/linux/Packaging.Linux/install-from-source.sh index 888a23597..5a0981d6e 100755 --- a/src/linux/Packaging.Linux/install-from-source.sh +++ b/src/linux/Packaging.Linux/install-from-source.sh @@ -91,7 +91,7 @@ ensure_dotnet_installed() { if [ -z "$(verify_existing_dotnet_installation)" ]; then curl -LO https://dot.net/v1/dotnet-install.sh chmod +x ./dotnet-install.sh - bash -c "./dotnet-install.sh --channel 8.0" + bash -c "./dotnet-install.sh --channel 10.0" # Since we have to run the dotnet install script with bash, dotnet isn't # added to the process PATH, so we manually add it here. @@ -103,10 +103,10 @@ ensure_dotnet_installed() { verify_existing_dotnet_installation() { # Get initial pieces of installed sdk version(s). - sdks=$(dotnet --list-sdks | cut -c 1-3) + sdks=$(dotnet --list-sdks | cut -d' ' -f1 | cut -d. -f1,2) # If we have a supported version installed, return. - supported_dotnet_versions="8.0" + supported_dotnet_versions="10.0" for v in $supported_dotnet_versions; do if [ $(echo $sdks | grep "$v") ]; then echo $sdks @@ -185,7 +185,7 @@ case "$distribution" in $sudo_cmd apt update $sudo_cmd apt install apt-transport-https -y $sudo_cmd apt update - $sudo_cmd apt install dotnet-sdk-8.0 dpkg-dev -y + $sudo_cmd apt install dotnet-sdk-10.0 dpkg-dev -y fi fi ;; From 9c6697e682cea9d2c898fdfe80ad15cf9fecf56b Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 11:18:48 +0100 Subject: [PATCH 61/62] ci: update debian container to bookworm for .NET 10 support .NET 10 no longer supports Debian 11 (bullseye). Update the install-from-source CI matrix to use Debian 12 (bookworm). Signed-off-by: Matthew John Cheetham --- .github/workflows/validate-install-from-source.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index beecc9ae1..9da7a5bd3 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -15,7 +15,7 @@ jobs: matrix: vector: - image: ubuntu - - image: debian:bullseye + - image: debian:bookworm - image: fedora # Centos no longer officially maintains images on Docker Hub. However, # tgagor is a contributor who pushes updated images weekly, which should From 14737f4b330a36b7746e9fd3c70ee5112f144b8f Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Tue, 28 Apr 2026 11:19:36 +0100 Subject: [PATCH 62/62] ci: run install-from-source validation on pull requests Signed-off-by: Matthew John Cheetham --- .github/workflows/validate-install-from-source.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/validate-install-from-source.yml b/.github/workflows/validate-install-from-source.yml index 9da7a5bd3..85c821eea 100644 --- a/.github/workflows/validate-install-from-source.yml +++ b/.github/workflows/validate-install-from-source.yml @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main jobs: docker: