|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the License.txt file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace Microsoft.Build.Tasks.Git
{
partial class GitConfig
{
internal sealed class LineCountingReader(TextReader reader, string path)
{
/// <summary>
/// 1-based current line number.
/// </summary>
private int _lineNumber = 1;
private int _last = -1;
public int Read()
{
var c = reader.Read();
if (c == '\r' || c == '\n' && _last != '\r')
{
_lineNumber++;
}
_last = c;
return c;
}
public int Peek()
=> reader.Peek();
public void UnexpectedCharacter()
=> UnexpectedCharacter(Peek());
public void UnexpectedCharacter(int c)
=> throw new InvalidDataException(string.Format(Resources.ErrorParsingConfigLineInFile, _lineNumber, path,
(c == -1) ? Resources.UnexpectedEndOfFile : string.Format(Resources.UnexpectedCharacter, $"U+{c:x4}")));
}
internal sealed class Reader
{
private const int MaxIncludeDepth = 10;
// reused for parsing names
private readonly StringBuilder _reusableBuffer = new StringBuilder();
// slash terminated posix path
private readonly string _gitDirectoryPosix;
private readonly string _commonDirectory;
private readonly Func<string, TextReader> _fileOpener;
private readonly GitEnvironment _environment;
internal Reader(string gitDirectory, string commonDirectory, GitEnvironment environment, Func<string, TextReader>? fileOpener = null)
{
NullableDebug.Assert(environment != null);
_environment = environment;
_gitDirectoryPosix = PathUtils.ToPosixDirectoryPath(gitDirectory);
_commonDirectory = commonDirectory;
_fileOpener = fileOpener ?? File.OpenText;
}
/// <exception cref="IOException"/>
/// <exception cref="InvalidDataException"/>
/// <exception cref="NotSupportedException"/>
internal GitConfig Load()
{
var variables = new Dictionary<GitVariableName, List<string>>();
foreach (var path in EnumerateExistingConfigurationFiles())
{
LoadVariablesFrom(path, variables, includeDepth: 0);
}
return new GitConfig(variables.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()));
}
/// <exception cref="IOException"/>
/// <exception cref="InvalidDataException"/>
/// <exception cref="NotSupportedException"/>
internal GitConfig LoadFrom(string path)
{
var variables = new Dictionary<GitVariableName, List<string>>();
LoadVariablesFrom(path, variables, includeDepth: 0);
return new GitConfig(variables.ToImmutableDictionary(kvp => kvp.Key, kvp => kvp.Value.ToImmutableArray()));
}
private string? GetXdgDirectory()
{
var xdgConfigHome = _environment.XdgConfigHomeDirectory;
if (xdgConfigHome != null)
{
return Path.Combine(xdgConfigHome, "git");
}
if (_environment.HomeDirectory != null)
{
return Path.Combine(_environment.HomeDirectory, ".config", "git");
}
return null;
}
internal IEnumerable<string> EnumerateExistingConfigurationFiles()
{
// program data (Windows only)
if (_environment.ProgramDataDirectory != null)
{
var programDataConfig = Path.Combine(_environment.ProgramDataDirectory, "git", "config");
if (File.Exists(programDataConfig))
{
yield return programDataConfig;
}
}
// system
var systemDir = GetSystemConfigurationDirectory();
if (systemDir != null)
{
var systemConfig = Path.Combine(systemDir, "gitconfig");
if (File.Exists(systemConfig))
{
yield return systemConfig;
}
}
// XDG
var xdgDir = GetXdgDirectory();
if (xdgDir != null)
{
var xdgConfig = Path.Combine(xdgDir, "config");
if (File.Exists(xdgConfig))
{
yield return xdgConfig;
}
}
// global (user home)
if (_environment.HomeDirectory != null)
{
var globalConfig = Path.Combine(_environment.HomeDirectory, ".gitconfig");
if (File.Exists(globalConfig))
{
yield return globalConfig;
}
}
// local
var localConfig = Path.Combine(_commonDirectory, "config");
if (File.Exists(localConfig))
{
yield return localConfig;
}
// TODO: https://github.com/dotnet/sourcelink/issues/303
// worktree config
}
private string? GetSystemConfigurationDirectory()
{
if (_environment.SystemDirectory == null)
{
return null;
}
if (!PathUtils.IsUnixLikePlatform)
{
// Git for Windows stores gitconfig under [install dir]\mingw64\etc,
// but other Git Windows implementations use [install dir]\etc.
var mingwEtc = Path.Combine(_environment.SystemDirectory, "..", "mingw64", "etc");
if (Directory.Exists(mingwEtc))
{
return mingwEtc;
}
}
return _environment.SystemDirectory;
}
/// <exception cref="IOException"/>
/// <exception cref="InvalidDataException"/>
internal void LoadVariablesFrom(string path, Dictionary<GitVariableName, List<string>> variables, int includeDepth)
{
// https://git-scm.com/docs/git-config#_syntax
// The following is allowed:
// [section][section]var = x
// [section]#[section]
if (includeDepth > MaxIncludeDepth)
{
throw new InvalidDataException(string.Format(Resources.ConfigurationFileRecursionExceededMaximumAllowedDepth, MaxIncludeDepth));
}
TextReader textReader;
try
{
textReader = _fileOpener(path);
}
catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException)
{
return;
}
catch (Exception e) when (e is not IOException)
{
throw new IOException(e.Message, e);
}
using (textReader)
{
var reader = new LineCountingReader(textReader, path);
var sectionName = "";
var subsectionName = "";
while (true)
{
SkipMultilineWhitespace(reader);
var c = reader.Peek();
if (c == -1)
{
break;
}
// Comment to the end of the line:
if (IsCommentStart(c))
{
ReadToLineEnd(reader);
continue;
}
if (c == '[')
{
ReadSectionHeader(reader, _reusableBuffer, out sectionName, out subsectionName);
continue;
}
ReadVariableDeclaration(reader, _reusableBuffer, out var variableName, out var variableValue);
// Variable declared outside of a section is allowed (has no section name prefix).
var key = new GitVariableName(sectionName, subsectionName, variableName);
if (!variables.TryGetValue(key, out var values))
{
variables.Add(key, values = new List<string>());
}
values.Add(variableValue);
// Spec https://git-scm.com/docs/git-config#_includes:
if (IsIncludePath(key, path))
{
var includedConfigPath = NormalizeRelativePath(relativePath: variableValue, basePath: path, key);
LoadVariablesFrom(includedConfigPath, variables, includeDepth + 1);
}
}
}
}
/// <exception cref="InvalidDataException"/>
private string NormalizeRelativePath(string relativePath, string basePath, GitVariableName key)
{
string root;
if (relativePath.Length >= 2 && relativePath[0] == '~' && PathUtils.IsDirectorySeparator(relativePath[1]))
{
root = _environment.GetHomeDirectoryForPathExpansion(relativePath);
relativePath = relativePath[2..];
}
else
{
root = Path.GetDirectoryName(basePath) ?? "";
}
try
{
return Path.GetFullPath(Path.Combine(root, relativePath));
}
catch
{
throw new InvalidDataException(string.Format(Resources.ValueOfIsNotValidPath, key.ToString(), relativePath));
}
}
private bool IsIncludePath(GitVariableName key, string configFilePath)
{
// unconditional:
if (key.Equals(new GitVariableName("include", "", "path")))
{
return true;
}
// conditional:
if (GitVariableName.SectionNameComparer.Equals(key.SectionName, "includeIf") &&
GitVariableName.VariableNameComparer.Equals(key.VariableName, "path") &&
key.SubsectionName != "")
{
bool ignoreCase;
string pattern;
const string caseSensitiveGitDirPrefix = "gitdir:";
const string caseInsensitiveGitDirPrefix = "gitdir/i:";
if (key.SubsectionName.StartsWith(caseSensitiveGitDirPrefix, StringComparison.Ordinal))
{
pattern = key.SubsectionName[caseSensitiveGitDirPrefix.Length..];
ignoreCase = false;
}
else if (key.SubsectionName.StartsWith(caseInsensitiveGitDirPrefix, StringComparison.Ordinal))
{
pattern = key.SubsectionName[caseInsensitiveGitDirPrefix.Length..];
ignoreCase = true;
}
else
{
return false;
}
if (pattern.Length >= 2 && pattern[0] == '.' && pattern[1] == '/')
{
// leading './' is substituted with the path to the directory containing the current config file.
pattern = PathUtils.CombinePosixPaths(PathUtils.ToPosixPath(Path.GetDirectoryName(configFilePath)!), pattern[2..]);
}
else if (pattern.Length >= 2 && pattern[0] == '~' && pattern[1] == '/')
{
// leading '~/' is substituted with HOME path
pattern = PathUtils.CombinePosixPaths(PathUtils.ToPosixPath(_environment.GetHomeDirectoryForPathExpansion(pattern)), pattern[2..]);
}
else if (!PathUtils.IsAbsolute(pattern))
{
pattern = "**/" + pattern;
}
if (pattern[pattern.Length - 1] == '/')
{
pattern += "**";
}
return Glob.IsMatch(pattern, _gitDirectoryPosix, ignoreCase, matchWildCardWithDirectorySeparator: true);
}
return false;
}
// internal for testing
internal static void ReadSectionHeader(LineCountingReader reader, StringBuilder reusableBuffer, out string name, out string subsectionName)
{
var nameBuilder = reusableBuffer.Clear();
var c = reader.Read();
Debug.Assert(c == '[');
while (true)
{
c = reader.Read();
if (c == ']')
{
name = nameBuilder.ToString();
subsectionName = "";
break;
}
if (IsWhitespace(c))
{
name = nameBuilder.ToString();
subsectionName = ReadSubsectionName(reader, reusableBuffer);
c = reader.Read();
if (c != ']')
{
reader.UnexpectedCharacter(c);
}
break;
}
if (IsAlphaNumeric(c) || c == '-' || c == '.')
{
// Allowed characters: alpha-numeric, '-', '.'; no restriction on the name start character.
nameBuilder.Append((char)c);
}
else
{
reader.UnexpectedCharacter(c);
}
}
name = name.ToLowerInvariant();
// Deprecated syntax: [section.subsection]
var firstDot = name.IndexOf('.');
if (firstDot != -1)
{
// "[.x]" parses to section "", subsection ".x" (lookup ".x.var" suceeds, ".X.var" fails)
// "[..x]" parses to section ".", subsection "x" (lookup "..x.var" suceeds, "..X.var" fails)
// "[x.]" parses to section "x.", subsection "" (lookups "X..var" and "x..var" suceed)
// "[x..]" parses to section "x", subsection "." (lookups "X...var" and "x...var" suceed)
var prefix = (firstDot == name.Length - 1) ? name : name[..firstDot];
var suffix = name[(firstDot + 1)..];
subsectionName = (subsectionName.Length > 0) ? suffix + "." + subsectionName : suffix;
name = prefix;
}
}
private static string ReadSubsectionName(LineCountingReader reader, StringBuilder reusableBuffer)
{
SkipWhitespace(reader);
var c = reader.Read();
if (c != '"')
{
reader.UnexpectedCharacter(c);
}
var subsectionName = reusableBuffer.Clear();
while (true)
{
c = reader.Read();
if (c <= 0)
{
reader.UnexpectedCharacter(c);
}
if (c == '"')
{
return subsectionName.ToString();
}
// Escaping: backslashes are skipped.
// Section headers can't span multiple lines.
if (c == '\\')
{
c = reader.Read();
if (c <= 0)
{
reader.UnexpectedCharacter(c);
}
}
subsectionName.Append((char)c);
}
}
// internal for testing
internal static void ReadVariableDeclaration(LineCountingReader reader, StringBuilder reusableBuffer, out string name, out string value)
{
name = ReadVariableName(reader, reusableBuffer);
if (name.Length == 0)
{
reader.UnexpectedCharacter();
}
SkipWhitespace(reader);
// Not allowed:
// name #
// = value
var c = reader.Peek();
if (c == -1 || IsCommentStart(c) || IsEndOfLine(c))
{
ReadToLineEnd(reader);
// If the value is not specified the variable is considered of type Boolean with value "true"
value = "true";
return;
}
if (c != '=')
{
reader.UnexpectedCharacter(c);
}
reader.Read();
SkipWhitespace(reader);
value = ReadVariableValue(reader, reusableBuffer);
}
private static string ReadVariableName(LineCountingReader reader, StringBuilder reusableBuffer)
{
var nameBuilder = reusableBuffer.Clear();
int c;
// Allowed characters: alpha-numeric, '-'; starts with alphabetic.
while (IsAlphabetic(c = reader.Peek()) || (c == '-' || IsNumeric(c)) && nameBuilder.Length > 0)
{
nameBuilder.Append((char)c);
reader.Read();
}
return nameBuilder.ToString().ToLowerInvariant();
}
private static string ReadVariableValue(LineCountingReader reader, StringBuilder reusableBuffer)
{
// Allowed:
// name = "a"x"b" `axb`
// name = "a
// b" `a\n b`
// name = "b"#"a" `b`
// name = \
// abc `abc`
// name = "a\
// bc" `a bc`
// name = a\
// bc `abc`
// name = a\
// bc `a bc`
// read until comment/eoln, quote
var inQuotes = false;
var builder = reusableBuffer.Clear();
var lengthIgnoringTrailingWhitespace = 0;
while (true)
{
var c = reader.Read();
if (c == -1)
{
if (inQuotes)
{
reader.UnexpectedCharacter(c);
}
break;
}
if (IsEndOfLine(c))
{
if (inQuotes)
{
builder.Append((char)c);
continue;
}
break;
}
if (c == '\\')
{
switch (reader.Peek())
{
case '\r':
case '\n':
ReadToLineEnd(reader);
continue;
case 'n':
reader.Read();
builder.Append('\n');
// escaped \n is not considered trailing whitespace:
lengthIgnoringTrailingWhitespace = builder.Length;
continue;
case 't':
reader.Read();
builder.Append('\t');
// escaped \t is not considered trailing whitespace:
lengthIgnoringTrailingWhitespace = builder.Length;
continue;
case 'b':
reader.Read();
builder.Append('\b');
// escaped \b is not considered trailing whitespace:
lengthIgnoringTrailingWhitespace = builder.Length;
continue;
case '\\':
case '"':
builder.Append((char)reader.Read());
lengthIgnoringTrailingWhitespace = builder.Length;
continue;
default:
reader.UnexpectedCharacter(c);
break;
}
}
if (c == '"')
{
inQuotes = !inQuotes;
continue;
}
if (IsCommentStart(c) && !inQuotes)
{
ReadToLineEnd(reader);
break;
}
builder.Append((char)c);
if (!IsWhitespace(c) || inQuotes)
{
lengthIgnoringTrailingWhitespace = builder.Length;
}
}
return builder.ToString(0, lengthIgnoringTrailingWhitespace);
}
private static void SkipMultilineWhitespace(LineCountingReader reader)
{
while (IsWhitespaceOrEndOfLine(reader.Peek()))
{
reader.Read();
}
}
private static void SkipWhitespace(LineCountingReader reader)
{
while (IsWhitespace(reader.Peek()))
{
reader.Read();
}
}
private static void ReadToLineEnd(LineCountingReader reader)
{
while (true)
{
var c = reader.Read();
if (c == -1)
{
return;
}
if (c == '\r')
{
if (reader.Peek() == '\n')
{
reader.Read();
return;
}
return;
}
if (c == '\n')
{
return;
}
}
}
private static bool IsCommentStart(int c)
=> c is ';' or '#';
private static bool IsAlphabetic(int c)
=> c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';
private static bool IsNumeric(int c)
=> c is >= '0' and <= '9';
private static bool IsAlphaNumeric(int c)
=> IsAlphabetic(c) || IsNumeric(c);
private static bool IsWhitespace(int c)
=> c is ' ' or '\t' or '\f' or '\v';
private static bool IsEndOfLine(int c)
=> c is '\r' or '\n';
private static bool IsWhitespaceOrEndOfLine(int c)
=> IsWhitespace(c) || IsEndOfLine(c);
}
}
}
|