Skip to content
Draft
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
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 66 additions & 25 deletions src/AzureSignTool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ static string GetVersion()
internal sealed class SignCommand : Command
{
private HashSet<string>? _allFiles;
private List<string> Files { get; set; } = [];

internal List<string> Files { get; set; } = [];
internal string? KeyVaultUrl { get; set; }
internal string? KeyVaultClientId { get; set; }
internal string? KeyVaultClientSecret { get; set; }
Expand Down Expand Up @@ -100,52 +100,76 @@ internal HashSet<string> AllFiles
if (_allFiles is null)
{
_allFiles = [];
Matcher matcher = new();

foreach (string file in Files)
{
Add(_allFiles, matcher, file);
}

List<string> 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<string> 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<string> 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, ""));
Copy link
Preview

Copilot AI Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using string replacement to remove the root directory path is unsafe and can cause incorrect pattern matching. If the root directory path appears elsewhere in the glob pattern, it will be incorrectly replaced. Use Path.GetRelativePath(rootDir, absGlob) or substring operations based on the root directory length instead.

Suggested change
absMatcher.AddInclude(absGlob.Replace(rootDir, ""));
absMatcher.AddInclude(Path.GetRelativePath(rootDir, absGlob));

Copilot uses AI. Check for mistakes.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems "possible" on Linux paths but not paths with drives. @casuffitsharp what do you think?


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;
}
}

Expand Down Expand Up @@ -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;
Copy link
Preview

Copilot AI Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning an empty string when no wildcard is found is incorrect. This method should return the full path for non-glob patterns, or throw an exception since this method appears to be specifically for glob patterns based on its usage context.

Suggested change
return string.Empty;
throw new ArgumentException("Pattern does not contain a wildcard: " + fullPathPattern, nameof(fullPathPattern));

Copilot uses AI. Check for mistakes.

}

int lastSeparatorIndex = fullPathPattern.LastIndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], firstWildcardIndex);
if (lastSeparatorIndex == -1)
{
return Path.GetPathRoot(fullPathPattern) ?? string.Empty;
Copy link
Preview

Copilot AI Aug 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When no directory separator is found before the wildcard, returning just the path root (e.g., 'C:\') is likely incorrect for most glob patterns. This would cause the matcher to search from the root drive rather than the intended directory. Consider whether this scenario represents an invalid glob pattern that should be handled differently.

Suggested change
return Path.GetPathRoot(fullPathPattern) ?? string.Empty;
// If no directory separator is found before the wildcard, return the current working directory.
return Environment.CurrentDirectory;

Copilot uses AI. Check for mistakes.

}

return fullPathPattern[..lastSeparatorIndex];
}

private static readonly string[] s_hashAlgorithm = ["SHA1", "SHA256", "SHA384", "SHA512"];
}
}
2 changes: 2 additions & 0 deletions src/AzureSignTool/Properties.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AzureSignTool.Tests")]
137 changes: 137 additions & 0 deletions test/AzureSignTool.Tests/SignCommandTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}