From 5f6d32ae4e43694b0b7e329d5c87fcf4d7a1348a Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 24 Jan 2025 11:56:59 +0000 Subject: [PATCH 1/2] macospreferences: add class to read macOS app preferences --- .../Interop/MacOS/MacOSPreferencesTests.cs | 66 ++++++++++ .../Core/Interop/MacOS/MacOSKeychain.cs | 33 ++--- .../Core/Interop/MacOS/MacOSPreferences.cs | 96 ++++++++++++++ .../Interop/MacOS/Native/CoreFoundation.cs | 119 ++++++++++++++++++ src/shared/TestInfrastructure/TestUtils.cs | 38 ++++++ 5 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs create mode 100644 src/shared/Core/Interop/MacOS/MacOSPreferences.cs diff --git a/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs b/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs new file mode 100644 index 000000000..0efb14471 --- /dev/null +++ b/src/shared/Core.Tests/Interop/MacOS/MacOSPreferencesTests.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using GitCredentialManager.Interop.MacOS; +using static GitCredentialManager.Tests.TestUtils; + +namespace GitCredentialManager.Tests.Interop.MacOS; + +public class MacOSPreferencesTests +{ + private const string TestAppId = "com.example.gcm-test"; + private const string DefaultsPath = "/usr/bin/defaults"; + + [MacOSFact] + public async Task MacOSPreferences_ReadPreferences() + { + try + { + await SetupTestPreferencesAsync(); + + var pref = new MacOSPreferences(TestAppId); + + // Exists + string stringValue = pref.GetString("myString"); + int? intValue = pref.GetInteger("myInt"); + IDictionary dictValue = pref.GetDictionary("myDict"); + + Assert.NotNull(stringValue); + Assert.Equal("this is a string", stringValue); + Assert.NotNull(intValue); + Assert.Equal(42, intValue); + Assert.NotNull(dictValue); + Assert.Equal(2, dictValue.Count); + Assert.Equal("value1", dictValue["dict-k1"]); + Assert.Equal("value2", dictValue["dict-k2"]); + + // Does not exist + string missingString = pref.GetString("missingString"); + int? missingInt = pref.GetInteger("missingInt"); + IDictionary missingDict = pref.GetDictionary("missingDict"); + + Assert.Null(missingString); + Assert.Null(missingInt); + Assert.Null(missingDict); + } + finally + { + await CleanupTestPreferencesAsync(); + } + } + + private static async Task SetupTestPreferencesAsync() + { + // Using the defaults command set up preferences for the test app + await RunCommandAsync(DefaultsPath, $"write {TestAppId} myString \"this is a string\""); + await RunCommandAsync(DefaultsPath, $"write {TestAppId} myInt -int 42"); + await RunCommandAsync(DefaultsPath, $"write {TestAppId} myDict -dict dict-k1 value1 dict-k2 value2"); + } + + private static async Task CleanupTestPreferencesAsync() + { + // Delete the test app preferences + // defaults delete com.example.gcm-test + await RunCommandAsync(DefaultsPath, $"delete {TestAppId}"); + } +} diff --git a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs index b024be129..9335e136d 100644 --- a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs +++ b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs @@ -302,35 +302,18 @@ private static string GetStringAttribute(IntPtr dict, IntPtr key) return null; } - IntPtr buffer = IntPtr.Zero; - try + if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero) { - if (CFDictionaryGetValueIfPresent(dict, key, out IntPtr value) && value != IntPtr.Zero) + if (CFGetTypeID(value) == CFStringGetTypeID()) { - if (CFGetTypeID(value) == CFStringGetTypeID()) - { - int stringLength = (int)CFStringGetLength(value); - int bufferSize = stringLength + 1; - buffer = Marshal.AllocHGlobal(bufferSize); - if (CFStringGetCString(value, buffer, bufferSize, CFStringEncoding.kCFStringEncodingUTF8)) - { - return Marshal.PtrToStringAuto(buffer, stringLength); - } - } - - if (CFGetTypeID(value) == CFDataGetTypeID()) - { - int length = CFDataGetLength(value); - IntPtr ptr = CFDataGetBytePtr(value); - return Marshal.PtrToStringAuto(ptr, length); - } + return CFStringToString(value); } - } - finally - { - if (buffer != IntPtr.Zero) + + if (CFGetTypeID(value) == CFDataGetTypeID()) { - Marshal.FreeHGlobal(buffer); + int length = CFDataGetLength(value); + IntPtr ptr = CFDataGetBytePtr(value); + return Marshal.PtrToStringAuto(ptr, length); } } diff --git a/src/shared/Core/Interop/MacOS/MacOSPreferences.cs b/src/shared/Core/Interop/MacOS/MacOSPreferences.cs new file mode 100644 index 000000000..f866b30a8 --- /dev/null +++ b/src/shared/Core/Interop/MacOS/MacOSPreferences.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using GitCredentialManager.Interop.MacOS.Native; +using static GitCredentialManager.Interop.MacOS.Native.CoreFoundation; + +namespace GitCredentialManager.Interop.MacOS; + +public class MacOSPreferences +{ + private readonly string _appId; + + public MacOSPreferences(string appId) + { + EnsureArgument.NotNull(appId, nameof(appId)); + + _appId = appId; + } + + /// + /// Return a typed value from the app preferences. + /// + /// Preference name. + /// Thrown if the preference is not a string. + /// + /// or null if the preference with the given key does not exist. + /// + public string GetString(string key) + { + return TryGet(key, CFStringToString, out string value) + ? value + : null; + } + + /// + /// Return a typed value from the app preferences. + /// + /// Preference name. + /// Thrown if the preference is not an integer. + /// + /// or null if the preference with the given key does not exist. + /// + public int? GetInteger(string key) + { + return TryGet(key, CFNumberToInt32, out int value) + ? value + : null; + } + + /// + /// Return a typed value from the app preferences. + /// + /// Preference name. + /// Thrown if the preference is not a dictionary. + /// + /// or null if the preference with the given key does not exist. + /// + public IDictionary GetDictionary(string key) + { + return TryGet(key, CFDictionaryToDictionary, out IDictionary value) + ? value + : null; + } + + private bool TryGet(string key, Func converter, out T value) + { + IntPtr cfValue = IntPtr.Zero; + IntPtr keyPtr = IntPtr.Zero; + IntPtr appIdPtr = CreateAppIdPtr(); + + try + { + keyPtr = CFStringCreateWithCString(IntPtr.Zero, key, CFStringEncoding.kCFStringEncodingUTF8); + cfValue = CFPreferencesCopyAppValue(keyPtr, appIdPtr); + + if (cfValue == IntPtr.Zero) + { + value = default; + return false; + } + + value = converter(cfValue); + return true; + } + finally + { + if (cfValue != IntPtr.Zero) CFRelease(cfValue); + if (keyPtr != IntPtr.Zero) CFRelease(keyPtr); + if (appIdPtr != IntPtr.Zero) CFRelease(appIdPtr); + } + } + + private IntPtr CreateAppIdPtr() + { + return CFStringCreateWithCString(IntPtr.Zero, _appId, CFStringEncoding.kCFStringEncodingUTF8); + } +} diff --git a/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs b/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs index 0f32a383b..9cab2ca8f 100644 --- a/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs +++ b/src/shared/Core/Interop/MacOS/Native/CoreFoundation.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using static GitCredentialManager.Interop.MacOS.Native.LibSystem; @@ -55,6 +56,9 @@ public static extern void CFDictionaryAddValue( public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes, long numBytes, CFStringEncoding encoding, bool isExternalRepresentation); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFStringCreateWithCString(IntPtr alloc, string cStr, CFStringEncoding encoding); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern long CFStringGetLength(IntPtr theString); @@ -82,15 +86,130 @@ public static extern IntPtr CFStringCreateWithBytes(IntPtr alloc, byte[] bytes, [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int CFArrayGetTypeID(); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern int CFNumberGetTypeID(); + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr CFDataGetBytePtr(IntPtr theData); [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int CFDataGetLength(IntPtr theData); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFPreferencesCopyAppValue(IntPtr key, IntPtr appID); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern bool CFNumberGetValue(IntPtr number, CFNumberType theType, out IntPtr valuePtr); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr CFDictionaryGetKeysAndValues(IntPtr theDict, IntPtr[] keys, IntPtr[] values); + + [DllImport(CoreFoundationFrameworkLib, CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] + public static extern long CFDictionaryGetCount(IntPtr theDict); + + public static string CFStringToString(IntPtr cfString) + { + if (cfString == IntPtr.Zero) + { + throw new ArgumentNullException(nameof(cfString)); + } + + if (CFGetTypeID(cfString) != CFStringGetTypeID()) + { + throw new InvalidOperationException("Object is not a CFString."); + } + + long length = CFStringGetLength(cfString); + IntPtr buffer = Marshal.AllocHGlobal((int)length + 1); + + try + { + if (!CFStringGetCString(cfString, buffer, length + 1, CFStringEncoding.kCFStringEncodingUTF8)) + { + throw new InvalidOperationException("Failed to convert CFString to C string."); + } + + return Marshal.PtrToStringAnsi(buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + public static int CFNumberToInt32(IntPtr cfNumber) + { + if (cfNumber == IntPtr.Zero) + { + throw new ArgumentNullException(nameof(cfNumber)); + } + + if (CFGetTypeID(cfNumber) != CFNumberGetTypeID()) + { + throw new InvalidOperationException("Object is not a CFNumber."); + } + + if (!CFNumberGetValue(cfNumber, CFNumberType.kCFNumberIntType, out IntPtr valuePtr)) + { + throw new InvalidOperationException("Failed to convert CFNumber to Int32."); + } + + return valuePtr.ToInt32(); + } + + public static IDictionary CFDictionaryToDictionary(IntPtr cfDict) + { + if (cfDict == IntPtr.Zero) + { + throw new ArgumentNullException(nameof(cfDict)); + } + + if (CFGetTypeID(cfDict) != CFDictionaryGetTypeID()) + { + throw new InvalidOperationException("Object is not a CFDictionary."); + } + + int count = (int)CFDictionaryGetCount(cfDict); + var keys = new IntPtr[count]; + var values = new IntPtr[count]; + + CFDictionaryGetKeysAndValues(cfDict, keys, values); + + var dict = new Dictionary(capacity: count); + for (int i = 0; i < count; i++) + { + string keyStr = CFStringToString(keys[i])!; + string valueStr = CFStringToString(values[i]); + + dict[keyStr] = valueStr; + } + + return dict; + } } public enum CFStringEncoding { kCFStringEncodingUTF8 = 0x08000100, } + + public enum CFNumberType + { + kCFNumberSInt8Type = 1, + kCFNumberSInt16Type = 2, + kCFNumberSInt32Type = 3, + kCFNumberSInt64Type = 4, + kCFNumberFloat32Type = 5, + kCFNumberFloat64Type = 6, + kCFNumberCharType = 7, + kCFNumberShortType = 8, + kCFNumberIntType = 9, + kCFNumberLongType = 10, + kCFNumberLongLongType = 11, + kCFNumberFloatType = 12, + kCFNumberDoubleType = 13, + kCFNumberCFIndexType = 14, + kCFNumberNSIntegerType = 15, + kCFNumberCGFloatType = 16 + } } diff --git a/src/shared/TestInfrastructure/TestUtils.cs b/src/shared/TestInfrastructure/TestUtils.cs index c547856d7..000b8e75e 100644 --- a/src/shared/TestInfrastructure/TestUtils.cs +++ b/src/shared/TestInfrastructure/TestUtils.cs @@ -1,5 +1,7 @@ using System; +using System.Diagnostics; using System.IO; +using System.Threading.Tasks; namespace GitCredentialManager.Tests { @@ -87,5 +89,41 @@ public static string GetUuid(int length = -1) return uuid.Substring(0, length); } + + public static async Task RunCommandAsync(string filePath, string arguments, string workingDirectory = null) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = filePath, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + } + }; + + process.Start(); + + string output = await process.StandardOutput.ReadToEndAsync(); + string error = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Command `{filePath} {arguments}` failed with exit code {process.ExitCode}." + + Environment.NewLine + + $"Output: {output}" + + Environment.NewLine + + $"Error: {error}"); + } + + return output; + } } } From b05317f74562e087b56ce8e7fdfc44e33c400589 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 24 Jan 2025 12:07:18 +0000 Subject: [PATCH 2/2] macossettings: implement default settings for macOS --- docs/enterprise-config.md | 33 ++++++++- src/shared/Core/CommandContext.cs | 2 +- src/shared/Core/Constants.cs | 1 + .../Core/Interop/MacOS/MacOSSettings.cs | 67 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/shared/Core/Interop/MacOS/MacOSSettings.cs diff --git a/docs/enterprise-config.md b/docs/enterprise-config.md index bfdc7e302..97544a33f 100644 --- a/docs/enterprise-config.md +++ b/docs/enterprise-config.md @@ -55,7 +55,38 @@ 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 settings values come from macOS's preferences system. Configuration +profiles can be deployed to devices using a compatible Mobile Device Management +(MDM) solution. + +Configuration for Git Credential Manager must take the form of a dictionary, set +for the domain `git-credential-manager` under the key `configuration`. For +example: + +```shell +defaults write git-credential-manager configuration -dict-add +``` + +..where `` is the name of the settings from the [Git configuration][config] +reference, and `` is the desired value. + +All values in the `configuration` dictionary must be strings. For boolean values +use `true` or `false`, and for integer values use the number in string form. + +To read the current configuration: + +```console +$ defaults read git-credential-manager configuration +{ + = ; + ... + = ; +} +``` + +## Linux Default configuration setting stores has not been implemented. diff --git a/src/shared/Core/CommandContext.cs b/src/shared/Core/CommandContext.cs index 712db32e1..d3ef1dbf6 100644 --- a/src/shared/Core/CommandContext.cs +++ b/src/shared/Core/CommandContext.cs @@ -131,7 +131,7 @@ public CommandContext() gitPath, FileSystem.GetCurrentDirectory() ); - Settings = new Settings(Environment, Git); + Settings = new MacOSSettings(Environment, Git, Trace); } else if (PlatformUtils.IsLinux()) { diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 191fcc83d..4777b0cf8 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -16,6 +16,7 @@ public static class Constants public const string GcmDataDirectoryName = ".gcm"; + public const string MacOSBundleId = "git-credential-manager"; public static readonly Guid DevBoxPartnerId = new("e3171dd9-9a5f-e5be-b36c-cc7c4f3f3bcf"); /// diff --git a/src/shared/Core/Interop/MacOS/MacOSSettings.cs b/src/shared/Core/Interop/MacOS/MacOSSettings.cs new file mode 100644 index 000000000..3ef2c8247 --- /dev/null +++ b/src/shared/Core/Interop/MacOS/MacOSSettings.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace GitCredentialManager.Interop.MacOS +{ + /// + /// Reads settings from Git configuration, environment variables, and defaults from the system. + /// + public class MacOSSettings : Settings + { + private readonly ITrace _trace; + + public MacOSSettings(IEnvironment environment, IGit git, ITrace trace) + : base(environment, git) + { + EnsureArgument.NotNull(trace, nameof(trace)); + _trace = trace; + + PlatformUtils.EnsureMacOS(); + } + + protected override bool TryGetExternalDefault(string section, string scope, string property, out string value) + { + value = null; + + try + { + // Check for app default preferences for our bundle ID. + // Defaults can be deployed system administrators via device management profiles. + var prefs = new MacOSPreferences(Constants.MacOSBundleId); + IDictionary dict = prefs.GetDictionary("configuration"); + + if (dict is null) + { + // No configuration key exists + return false; + } + + // Wrap the raw dictionary in one configured with the Git configuration key comparer. + // This means we can use the same key comparison rules as Git in our configuration plist dict, + // That is, sections and names are insensitive to case, but the scope is case-sensitive. + var config = new Dictionary(dict, GitConfigurationKeyComparer.Instance); + + string name = string.IsNullOrWhiteSpace(scope) + ? $"{section}.{property}" + : $"{section}.{scope}.{property}"; + + if (!config.TryGetValue(name, out value)) + { + // No property exists + return false; + } + + _trace.WriteLine($"Default setting found in app preferences: {name}={value}"); + return true; + } + 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 preferences."); + _trace.WriteException(ex); + return false; + } + } + } +}