From bb94df1115ef0de30f19f40d16384d14355009e0 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 24 Jan 2025 14:39:16 +0000 Subject: [PATCH] linuxsettings: implement default settings for Linux --- docs/enterprise-config.md | 35 ++++++- .../Interop/Linux/LinuxConfigParserTests.cs | 57 ++++++++++++ .../Interop/Linux/LinuxSettingsTests.cs | 47 ++++++++++ src/shared/Core/CommandContext.cs | 2 +- src/shared/Core/Constants.cs | 1 + .../Core/Interop/Linux/LinuxConfigParser.cs | 62 +++++++++++++ .../Core/Interop/Linux/LinuxSettings.cs | 93 +++++++++++++++++++ .../Core/Interop/Windows/WindowsSettings.cs | 2 +- src/shared/Core/Settings.cs | 2 +- 9 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 src/shared/Core.Tests/Interop/Linux/LinuxConfigParserTests.cs create mode 100644 src/shared/Core.Tests/Interop/Linux/LinuxSettingsTests.cs create mode 100644 src/shared/Core/Interop/Linux/LinuxConfigParser.cs create mode 100644 src/shared/Core/Interop/Linux/LinuxSettings.cs diff --git a/docs/enterprise-config.md b/docs/enterprise-config.md index bfdc7e302..a7cbb133e 100644 --- a/docs/enterprise-config.md +++ b/docs/enterprise-config.md @@ -55,9 +55,42 @@ those of the [Git configuration][config] settings. The type of each registry key can be either `REG_SZ` (string) or `REG_DWORD` (integer). -## macOS/Linux +## macOS Default configuration setting stores has not been implemented. +## Linux + +Default settings values come from the `/etc/git-credential-manager/config.d` +directory. Each file in this directory represents a single settings dictionary. + +All files in this directory are read at runtime and merged into a single +collection of settings, in the order they are read from the file system. +To provide a stable ordering, it is recommended to prefix each filename with a +number, e.g. `42-my-settings`. + +The format of each file is a simple set of key/value pairs, separated by an +`=` sign, and each line separated by a line-feed (`\n`, LF) character. +Comments are identified by a `#` character at the beginning of a line. + +For example: + +```text +# /etc/git-credential-manager/config.d/00-example1 +credential.noguiprompt=0 +``` + +```text +# /etc/git-credential-manager/config.d/01-example2 +credential.trace=true +credential.traceMsAuth=true +``` + +All settings names and values are the same as the [Git configuration][config] +reference. + +> Note: These files are read once at startup. If changes are made to these files +they will not be reflected in an already running process. + [environment]: environment.md [config]: configuration.md diff --git a/src/shared/Core.Tests/Interop/Linux/LinuxConfigParserTests.cs b/src/shared/Core.Tests/Interop/Linux/LinuxConfigParserTests.cs new file mode 100644 index 000000000..b31a3be30 --- /dev/null +++ b/src/shared/Core.Tests/Interop/Linux/LinuxConfigParserTests.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using GitCredentialManager.Interop.Linux; +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace GitCredentialManager.Tests.Interop.Linux; + +public class LinuxConfigParserTests +{ + [Fact] + public void LinuxConfigParser_Parse() + { + const string contents = + """ + # + # This is a config file complete with comments + # and empty.. + + # lines, as well as lines with.. + # + # only whitespace (like above ^), and.. + invalid lines like this one, not a comment + # Here's the first real properties: + core.overrideMe=This is the first config value + baz.specialChars=I contain special chars like = in my value # this is a comment + # and let's have with a comment that also contains a = in side + # + core.overrideMe=This is the second config value + bar.scope.foo=123456 + core.overrideMe=This is the correct value + ###### comments that start ## with whitespace and extra ## inside + strings.one="here we have a dq string" + strings.two='here we have a sq string' + strings.three= 'here we have another sq string' # have another sq string + strings.four="this has 'nested quotes' inside" + strings.five='mixed "quotes" the other way around' + strings.six='this has an \'escaped\' set of quotes' + """; + + var expected = new Dictionary + { + ["core.overrideMe"] = "This is the correct value", + ["bar.scope.foo"] = "123456", + ["baz.specialChars"] = "I contain special chars like = in my value", + ["strings.one"] = "here we have a dq string", + ["strings.two"] = "here we have a sq string", + ["strings.three"] = "here we have another sq string", + ["strings.four"] = "this has 'nested quotes' inside", + ["strings.five"] = "mixed \"quotes\" the other way around", + ["strings.six"] = "this has an \\'escaped\\' set of quotes", + }; + + var parser = new LinuxConfigParser(new NullTrace()); + + Assert.Equal(expected, parser.Parse(contents)); + } +} \ No newline at end of file diff --git a/src/shared/Core.Tests/Interop/Linux/LinuxSettingsTests.cs b/src/shared/Core.Tests/Interop/Linux/LinuxSettingsTests.cs new file mode 100644 index 000000000..7b9d7e893 --- /dev/null +++ b/src/shared/Core.Tests/Interop/Linux/LinuxSettingsTests.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using GitCredentialManager.Interop.Linux; +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace GitCredentialManager.Tests.Interop.Linux; + +public class LinuxSettingsTests +{ + [LinuxFact] + public void LinuxSettings_TryGetExternalDefault_CombinesFiles() + { + var env = new TestEnvironment(); + var git = new TestGit(); + var trace = new NullTrace(); + var fs = new TestFileSystem(); + + var utf8 = EncodingEx.UTF8NoBom; + + fs.Directories = new HashSet + { + "/", + "/etc", + "/etc/git-credential-manager", + "/etc/git-credential-manager/config.d" + }; + + const string config1 = "core.overrideMe=value1"; + const string config2 = "core.overrideMe=value2"; + const string config3 = "core.overrideMe=value3"; + + fs.Files = new Dictionary + { + ["/etc/git-credential-manager/config.d/01-first"] = utf8.GetBytes(config1), + ["/etc/git-credential-manager/config.d/02-second"] = utf8.GetBytes(config2), + ["/etc/git-credential-manager/config.d/03-third"] = utf8.GetBytes(config3), + }; + + var settings = new LinuxSettings(env, git, trace, fs); + + bool result = settings.TryGetExternalDefault( + "core", null, "overrideMe", out string value); + + Assert.True(result); + Assert.Equal("value3", value); + } +} diff --git a/src/shared/Core/CommandContext.cs b/src/shared/Core/CommandContext.cs index 712db32e1..f7f3e045b 100644 --- a/src/shared/Core/CommandContext.cs +++ b/src/shared/Core/CommandContext.cs @@ -148,7 +148,7 @@ public CommandContext() gitPath, FileSystem.GetCurrentDirectory() ); - Settings = new Settings(Environment, Git); + Settings = new LinuxSettings(Environment, Git, Trace, FileSystem); } else { diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 191fcc83d..1a811a6f1 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -15,6 +15,7 @@ public static class Constants public const string AuthorityIdAuto = "auto"; public const string GcmDataDirectoryName = ".gcm"; + public const string LinuxAppDefaultsDirectoryPath = "/etc/git-credential-manager/config.d"; public static readonly Guid DevBoxPartnerId = new("e3171dd9-9a5f-e5be-b36c-cc7c4f3f3bcf"); diff --git a/src/shared/Core/Interop/Linux/LinuxConfigParser.cs b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs new file mode 100644 index 000000000..1caa918fc --- /dev/null +++ b/src/shared/Core/Interop/Linux/LinuxConfigParser.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace GitCredentialManager.Interop.Linux; + +public class LinuxConfigParser +{ +#if NETFRAMEWORK + private const string SQ = "'"; + private const string DQ = "\""; + private const string Hash = "#"; +#else + private const char SQ = '\''; + private const char DQ = '"'; + private const char Hash = '#'; +#endif + + private static readonly Regex LineRegex = new(@"^\s*(?[a-zA-Z0-9\.-]+)\s*=\s*(?.+?)\s*(?:#.*)?$"); + + private readonly ITrace _trace; + + public LinuxConfigParser(ITrace trace) + { + EnsureArgument.NotNull(trace, nameof(trace)); + + _trace = trace; + } + + public IDictionary Parse(string content) + { + var result = new Dictionary(GitConfigurationKeyComparer.Instance); + + IEnumerable lines = content.Split(['\n'], StringSplitOptions.RemoveEmptyEntries); + + foreach (string line in lines) + { + // Ignore empty lines or full-line comments + var trimmedLine = line.Trim(); + if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith(Hash)) + continue; + + var match = LineRegex.Match(trimmedLine); + if (!match.Success) + { + _trace.WriteLine($"Invalid config line format: {line}"); + continue; + } + + string key = match.Groups["key"].Value; + string value = match.Groups["value"].Value; + + // Remove enclosing quotes from the value, if any + if ((value.StartsWith(DQ) && value.EndsWith(DQ)) || (value.StartsWith(SQ) && value.EndsWith(SQ))) + value = value.Substring(1, value.Length - 2); + + result[key] = value; + } + + return result; + } +} diff --git a/src/shared/Core/Interop/Linux/LinuxSettings.cs b/src/shared/Core/Interop/Linux/LinuxSettings.cs new file mode 100644 index 000000000..4f00420fd --- /dev/null +++ b/src/shared/Core/Interop/Linux/LinuxSettings.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Avalonia.Markup.Xaml.MarkupExtensions; + +namespace GitCredentialManager.Interop.Linux; + +public class LinuxSettings : Settings +{ + private readonly ITrace _trace; + private readonly IFileSystem _fs; + + private IDictionary _extConfigCache; + + /// + /// Reads settings from Git configuration, environment variables, and defaults from the + /// /etc/git-credential-manager.d app configuration directory. + /// + public LinuxSettings(IEnvironment environment, IGit git, ITrace trace, IFileSystem fs) + : base(environment, git) + { + EnsureArgument.NotNull(trace, nameof(trace)); + EnsureArgument.NotNull(fs, nameof(fs)); + + _trace = trace; + _fs = fs; + + PlatformUtils.EnsureLinux(); + } + + protected internal override bool TryGetExternalDefault(string section, string scope, string property, out string value) + { + value = null; + + _extConfigCache ??= ReadExternalConfiguration(); + + string name = string.IsNullOrWhiteSpace(scope) + ? $"{section}.{property}" + : $"{section}.{scope}.{property}"; + + // Check if the setting exists in the configuration + if (!_extConfigCache?.TryGetValue(name, out value) ?? false) + { + // No property exists (or failed to read config) + return false; + } + + _trace.WriteLine($"Default setting found in app configuration directory: {name}={value}"); + return true; + } + + private IDictionary ReadExternalConfiguration() + { + try + { + // Check for system-wide config files in /etc/git-credential-manager/config.d and concatenate them together + // in alphabetical order to form a single configuration. + const string configDir = Constants.LinuxAppDefaultsDirectoryPath; + if (!_fs.DirectoryExists(configDir)) + { + // No configuration directory exists + return null; + } + + // Get all the files in the configuration directory + IEnumerable files = _fs.EnumerateFiles(configDir, "*"); + + // Read the contents of each file and concatenate them together + var combinedFile = new StringBuilder(); + foreach (string file in files) + { + using Stream stream = _fs.OpenFileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new StreamReader(stream); + string contents = reader.ReadToEnd(); + combinedFile.Append(contents); + combinedFile.Append('\n'); + } + + var parser = new LinuxConfigParser(_trace); + + return parser.Parse(combinedFile.ToString()); + } + catch (Exception ex) + { + // Reading defaults is not critical to the operation of the application + // so we can ignore any errors and just log the failure. + _trace.WriteLine("Failed to read default setting from app configuration directory."); + _trace.WriteException(ex); + return null; + } + } +} \ No newline at end of file diff --git a/src/shared/Core/Interop/Windows/WindowsSettings.cs b/src/shared/Core/Interop/Windows/WindowsSettings.cs index abdd9ee0e..888e9aa7d 100644 --- a/src/shared/Core/Interop/Windows/WindowsSettings.cs +++ b/src/shared/Core/Interop/Windows/WindowsSettings.cs @@ -17,7 +17,7 @@ public WindowsSettings(IEnvironment environment, IGit git, ITrace trace) PlatformUtils.EnsureWindows(); } - protected override bool TryGetExternalDefault(string section, string scope, string property, out string value) + protected internal override bool TryGetExternalDefault(string section, string scope, string property, out string value) { value = null; diff --git a/src/shared/Core/Settings.cs b/src/shared/Core/Settings.cs index 0e24ce9a3..af3dcf99c 100644 --- a/src/shared/Core/Settings.cs +++ b/src/shared/Core/Settings.cs @@ -489,7 +489,7 @@ public IEnumerable GetSettingValues(string envarName, string section, st /// Configuration property name. /// Value of the configuration setting, or null. /// True if a default setting has been set, false otherwise. - protected virtual bool TryGetExternalDefault(string section, string scope, string property, out string value) + protected internal virtual bool TryGetExternalDefault(string section, string scope, string property, out string value) { value = null; return false;