diff --git a/.editorconfig b/.editorconfig index 33d602c..80412cc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,10 @@ # All files [*] indent_style = space +# Project files +[*.csproj] +indent_size = 2 +charset = utf-8 # Code files [*.{cs,csx,vb,vbx}] indent_size = 4 diff --git a/src/AzureSignTool/Program.cs b/src/AzureSignTool/Program.cs index a2cfc85..b170427 100644 --- a/src/AzureSignTool/Program.cs +++ b/src/AzureSignTool/Program.cs @@ -64,8 +64,8 @@ static string GetVersion() internal sealed class SignCommand : Command { private HashSet? _allFiles; - private List Files { get; set; } = []; + internal List Files { get; set; } = []; internal string? KeyVaultUrl { get; set; } internal string? KeyVaultClientId { get; set; } internal string? KeyVaultClientSecret { get; set; } @@ -100,52 +100,76 @@ internal HashSet AllFiles if (_allFiles is null) { _allFiles = []; - Matcher matcher = new(); - - foreach (string file in Files) - { - Add(_allFiles, matcher, file); - } - + List files = [..Files]; if (!string.IsNullOrWhiteSpace(InputFileList)) { - foreach(string line in File.ReadLines(InputFileList)) + foreach (string line in File.ReadLines(InputFileList)) { if (string.IsNullOrWhiteSpace(line)) { continue; } - Add(_allFiles, matcher, line); + files.Add(line); } } - PatternMatchingResult results = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo("."))); + List absGlobs = []; + Matcher relMatcher = new(); - if (results.HasMatches) + foreach (var file in files) { - foreach (var result in results.Files) + // We require explicit glob pattern wildcards in order to treat it as a glob. e.g. + // dir/ will not be treated as a directory. It must be explicitly dir/*.exe or dir/**/*.exe, for example. + if (file.Contains('*')) { - _allFiles.Add(result.Path); + if (Path.IsPathRooted(file)) + { + absGlobs.Add(file); + } + else + { + relMatcher.AddInclude(file); + } + } + else + { + _allFiles.Add(file); } } - } - - return _allFiles; - static void Add(HashSet collection, Matcher matcher, string item) - { - // We require explicit glob pattern wildcards in order to treat it as a glob. e.g. - // dir/ will not be treated as a directory. It must be explicitly dir/*.exe or dir/**/*.exe, for example. - if (item.Contains('*')) + PatternMatchingResult relResults = relMatcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo("."))); + if (relResults.HasMatches) { - matcher.AddInclude(item); + foreach (var result in relResults.Files) + { + _allFiles.Add(result.Path); + } } - else + + foreach (string absGlob in absGlobs) { - collection.Add(item); + string rootDir = GetPathRoot(absGlob); + if (!Directory.Exists(rootDir)) + { + continue; + } + + var absMatcher = new Matcher(StringComparison.OrdinalIgnoreCase); + absMatcher.AddInclude(absGlob.Replace(rootDir, "")); + + var absoluteResults = absMatcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(rootDir))); + if (absoluteResults.HasMatches) + { + foreach (var match in absoluteResults.Files) + { + _allFiles.Add(Path.GetFullPath(Path.Combine(rootDir, match.Path))); + } + } } } + + return _allFiles; } } @@ -618,6 +642,23 @@ private static bool OneTrue(params bool[] values) return count == 1; } + private static string GetPathRoot(string fullPathPattern) + { + int firstWildcardIndex = fullPathPattern.IndexOf('*'); + if (firstWildcardIndex == -1) + { + return string.Empty; + } + + int lastSeparatorIndex = fullPathPattern.LastIndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], firstWildcardIndex); + if (lastSeparatorIndex == -1) + { + return Path.GetPathRoot(fullPathPattern) ?? string.Empty; + } + + return fullPathPattern[..lastSeparatorIndex]; + } + private static readonly string[] s_hashAlgorithm = ["SHA1", "SHA256", "SHA384", "SHA512"]; } } diff --git a/src/AzureSignTool/Properties.cs b/src/AzureSignTool/Properties.cs new file mode 100644 index 0000000..1a6660d --- /dev/null +++ b/src/AzureSignTool/Properties.cs @@ -0,0 +1,2 @@ +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("AzureSignTool.Tests")] diff --git a/test/AzureSignTool.Tests/SignCommandTests.cs b/test/AzureSignTool.Tests/SignCommandTests.cs new file mode 100644 index 0000000..71fcb51 --- /dev/null +++ b/test/AzureSignTool.Tests/SignCommandTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using Xunit; + +namespace AzureSignTool.Tests; + +public class SignCommandTests +{ + [Fact] + public void AllFiles_WithAbsoluteGlobPath_FindsFileCorrectly() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"absolute-glob-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDirectory); + var testFilePath = Path.Combine(tempDirectory, "file-to-sign.txt"); + File.WriteAllText(testFilePath, "content"); + + var command = new SignCommand(); + var absoluteGlobPattern = Path.Combine(tempDirectory, "**", "*.txt"); + command.Files.Add(absoluteGlobPattern); + + try + { + var foundFiles = command.AllFiles; + var foundFile = Assert.Single(foundFiles); + Assert.Equal(Path.GetFullPath(testFilePath), foundFile, ignoreCase: true); + } + finally + { + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, recursive: true); + } + } + + [Fact] + public void AllFiles_WithSingleAbsoluteExistingFile_ReturnsOneFile() + { + var tempFilePath = Path.Combine(Path.GetTempPath(), $"single-file-test-{Guid.NewGuid()}.tmp"); + File.WriteAllText(tempFilePath, "content"); + + var command = new SignCommand(); + command.Files.Add(tempFilePath); + + try + { + var foundFiles = command.AllFiles; + var foundFile = Assert.Single(foundFiles); + Assert.Equal(Path.GetFullPath(tempFilePath), foundFile, ignoreCase: true); + } + finally + { + if (File.Exists(tempFilePath)) + File.Delete(tempFilePath); + } + } + + [Fact] + public void AllFiles_ShouldIncludeExplicitPath_WhenFileDoesNotExist() + { + var command = new SignCommand(); + var nonExistentFilePath = Path.GetFullPath(Path.Combine("non", "existent", "path", $"file-{Guid.NewGuid()}.dll")); + + command.Files.Add(nonExistentFilePath); + + var foundFiles = command.AllFiles; + + var foundFile = Assert.Single(foundFiles); + Assert.Equal(nonExistentFilePath, foundFile, ignoreCase: true); + } + + [Fact] + public void AllFiles_ShouldIncludeExplicitPath_WhenFileExists() + { + var tempFile = Path.GetTempFileName(); + var command = new SignCommand(); + command.Files.Add(tempFile); + + try + { + var foundFiles = command.AllFiles; + var foundFile = Assert.Single(foundFiles); + Assert.Equal(Path.GetFullPath(tempFile), foundFile, ignoreCase: true); + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + } + + [Fact] + public void AllFiles_ShouldReturnEmpty_WhenGlobMatchesNoFiles() + { + var tempDirectory = Path.Combine(Path.GetTempPath(), $"empty-glob-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDirectory); + + var command = new SignCommand(); + command.Files.Add(Path.Combine(tempDirectory, "*.nomatchtype")); + + try + { + var foundFiles = command.AllFiles; + Assert.Empty(foundFiles); + } + finally + { + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, true); + } + } + + [Fact] + public void AllFiles_ShouldReturnCombinedSet_ForMixedInputs() + { + var nonExistentFilePath = Path.GetFullPath(Path.Combine("c:", "path", "to", $"non-existent-file-{Guid.NewGuid()}.txt")); + + var tempDirectory = Path.Combine(Path.GetTempPath(), $"mixed-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(tempDirectory); + var globbedFilePath = Path.Combine(tempDirectory, "app.exe"); + File.WriteAllText(globbedFilePath, "content"); + + var command = new SignCommand(); + command.Files.Add(nonExistentFilePath); + command.Files.Add(Path.Combine(tempDirectory, "*.exe")); + + try + { + var foundFiles = command.AllFiles; + Assert.Equal(2, foundFiles.Count); + Assert.Contains(nonExistentFilePath, foundFiles, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Path.GetFullPath(globbedFilePath), foundFiles, StringComparer.OrdinalIgnoreCase); + } + finally + { + if (Directory.Exists(tempDirectory)) + Directory.Delete(tempDirectory, true); + } + } +}