Skip to content

Commit

Permalink
linuxsettings: implement default settings for Linux
Browse files Browse the repository at this point in the history
  • Loading branch information
mjcheetham committed Jan 27, 2025
1 parent 4c32c09 commit bb94df1
Show file tree
Hide file tree
Showing 9 changed files with 297 additions and 4 deletions.
35 changes: 34 additions & 1 deletion docs/enterprise-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
57 changes: 57 additions & 0 deletions src/shared/Core.Tests/Interop/Linux/LinuxConfigParserTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
["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));
}
}
47 changes: 47 additions & 0 deletions src/shared/Core.Tests/Interop/Linux/LinuxSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -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<string>
{
"/",
"/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<string, byte[]>
{
["/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);
}
}
2 changes: 1 addition & 1 deletion src/shared/Core/CommandContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ public CommandContext()
gitPath,
FileSystem.GetCurrentDirectory()
);
Settings = new Settings(Environment, Git);
Settings = new LinuxSettings(Environment, Git, Trace, FileSystem);
}
else
{
Expand Down
1 change: 1 addition & 0 deletions src/shared/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
62 changes: 62 additions & 0 deletions src/shared/Core/Interop/Linux/LinuxConfigParser.cs
Original file line number Diff line number Diff line change
@@ -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*(?<key>[a-zA-Z0-9\.-]+)\s*=\s*(?<value>.+?)\s*(?:#.*)?$");

private readonly ITrace _trace;

public LinuxConfigParser(ITrace trace)
{
EnsureArgument.NotNull(trace, nameof(trace));

_trace = trace;
}

public IDictionary<string, string> Parse(string content)
{
var result = new Dictionary<string, string>(GitConfigurationKeyComparer.Instance);

IEnumerable<string> 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;
}
}
93 changes: 93 additions & 0 deletions src/shared/Core/Interop/Linux/LinuxSettings.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> _extConfigCache;

/// <summary>
/// Reads settings from Git configuration, environment variables, and defaults from the
/// /etc/git-credential-manager.d app configuration directory.
/// </summary>
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<string, string> 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<string> 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;
}
}
}
2 changes: 1 addition & 1 deletion src/shared/Core/Interop/Windows/WindowsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/shared/Core/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ public IEnumerable<string> GetSettingValues(string envarName, string section, st
/// <param name="property">Configuration property name.</param>
/// <param name="value">Value of the configuration setting, or null.</param>
/// <returns>True if a default setting has been set, false otherwise.</returns>
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;
Expand Down

0 comments on commit bb94df1

Please sign in to comment.