Skip to content

Add support for Linux enterprise deployable default settings #1814

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion docs/enterprise-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,36 @@ $ defaults read git-credential-manager configuration

## Linux

Default configuration setting stores has not been implemented.
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 const string MacOSBundleId = "git-credential-manager";
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/MacOS/MacOSSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public MacOSSettings(IEnvironment environment, IGit git, ITrace trace)
PlatformUtils.EnsureMacOS();
}

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/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
Loading