diff --git a/AppInspector.CLI/CLICmdOptions.cs b/AppInspector.CLI/CLICmdOptions.cs index 6d493d3e..d4d01208 100644 --- a/AppInspector.CLI/CLICmdOptions.cs +++ b/AppInspector.CLI/CLICmdOptions.cs @@ -104,6 +104,10 @@ public record CLIAnalysisSharedCommandOptions : CLICustomRulesCommandOptions [Option('u', "scan-unknown-filetypes", Required = false, HelpText = "Scan files of unknown types.")] public bool ScanUnknownTypes { get; set; } + [Option("follow-symlinks", Required = false, + HelpText = "Follow symbolic links while enumerating source files.")] + public bool FollowSymlinks { get; set; } + [Option('c', "confidence-filters", Required = false, Separator = ',', HelpText = "Output only matches with specified confidence ,. Default: Medium,High. [High|Medium|Low]", diff --git a/AppInspector.CLI/Program.cs b/AppInspector.CLI/Program.cs index 3888e40a..22249a27 100644 --- a/AppInspector.CLI/Program.cs +++ b/AppInspector.CLI/Program.cs @@ -293,6 +293,7 @@ private static int RunAnalyzeCommand(CLIAnalyzeCmdOptions cliOptions) ProcessingTimeOut = cliOptions.ProcessingTimeOut, ContextLines = numContextLines, ScanUnknownTypes = cliOptions.ScanUnknownTypes, + FollowSymlinks = cliOptions.FollowSymlinks, TagsOnly = tagsOnly, NoFileMetadata = cliOptions.NoFileMetadata, AllowAllTagsInBuildFiles = cliOptions.AllowAllTagsInBuildFiles, @@ -372,6 +373,7 @@ private static int RunTagDiffCommand(CLITagDiffCmdOptions cliOptions) FileTimeOut = cliOptions.FileTimeOut, ProcessingTimeOut = cliOptions.ProcessingTimeOut, ScanUnknownTypes = cliOptions.ScanUnknownTypes, + FollowSymlinks = cliOptions.FollowSymlinks, SingleThread = cliOptions.SingleThread, DisableCustomRuleValidation = cliOptions.DisableCustomRuleValidation, DisableRequireUniqueIds = cliOptions.DisableRequireUniqueIds, diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index 4a7df6de..7ff86b2e 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -16,6 +16,65 @@ namespace AppInspector.Tests.Commands; +internal static class SymlinkTestSupport +{ + public static string? SkipReason { get; } = GetSkipReason(); + + private static string? GetSkipReason() + { + var testRoot = Path.Combine(Path.GetTempPath(), $"ApplicationInspector-SymlinkTest-{Guid.NewGuid()}"); + + try + { + Directory.CreateDirectory(testRoot); + var fileTarget = Path.Combine(testRoot, "target.txt"); + var directoryTarget = Path.Combine(testRoot, "target-directory"); + File.WriteAllText(fileTarget, string.Empty); + Directory.CreateDirectory(directoryTarget); + File.CreateSymbolicLink(Path.Combine(testRoot, "file-link"), fileTarget); + Directory.CreateSymbolicLink(Path.Combine(testRoot, "directory-link"), directoryTarget); + return null; + } + catch (UnauthorizedAccessException) + { + return "Symlink creation is not permitted on this machine."; + } + catch (PlatformNotSupportedException) + { + return "Symlinks are not supported on this platform."; + } + catch (IOException ex) + { + return $"Symlink creation is not supported by the test file system: {ex.Message}"; + } + finally + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, true); + } + } + } +} + +[AttributeUsage(AttributeTargets.Method)] +internal sealed class SymlinkFactAttribute : FactAttribute +{ + public SymlinkFactAttribute() + { + Skip = SymlinkTestSupport.SkipReason; + } +} + +[AttributeUsage(AttributeTargets.Method)] +internal sealed class SymlinkTheoryAttribute : TheoryAttribute +{ + public SymlinkTheoryAttribute() + { + Skip = SymlinkTestSupport.SkipReason; + } +} + public class TestAnalyzeCmdFixture : IDisposable { public TestAnalyzeCmdFixture() @@ -134,6 +193,85 @@ public void Overrides() Assert.Equal(2, result.Metadata.UniqueMatchesCount); } + [SymlinkTheory] + [InlineData(false, 4)] + [InlineData(true, 6)] + public void FollowSymlinks(bool followSymlinks, int expectedFiles) + { + var testRoot = Path.Combine("TestOutput", $"SymlinkTest-{Guid.NewGuid()}"); + var sourcePath = Path.Combine(testRoot, "source"); + var linkedDirectoryTarget = Path.Combine(testRoot, "linked-directory-target"); + Directory.CreateDirectory(sourcePath); + Directory.CreateDirectory(linkedDirectoryTarget); + + try + { + File.WriteAllText(Path.Combine(sourcePath, "regular.js"), "windows"); + var linkedFileTarget = Path.Combine(testRoot, "linked-file-target.js"); + File.WriteAllText(linkedFileTarget, "windows"); + File.WriteAllText(Path.Combine(linkedDirectoryTarget, "linked-directory-file.js"), "windows"); + File.CreateSymbolicLink(Path.Combine(sourcePath, "linked-file.js"), Path.GetFullPath(linkedFileTarget)); + Directory.CreateSymbolicLink(Path.Combine(sourcePath, "linked-directory"), + Path.GetFullPath(linkedDirectoryTarget)); + var directLinkedFile = Path.Combine(testRoot, "direct-linked-file.js"); + var directLinkedDirectory = Path.Combine(testRoot, "direct-linked-directory"); + File.CreateSymbolicLink(directLinkedFile, Path.GetFullPath(linkedFileTarget)); + Directory.CreateSymbolicLink(directLinkedDirectory, Path.GetFullPath(linkedDirectoryTarget)); + var linkedAncestorTarget = Path.Combine(testRoot, "linked-ancestor-target"); + var sourceThroughLinkedAncestor = Path.Combine(testRoot, "linked-ancestor", "source"); + Directory.CreateDirectory(Path.Combine(linkedAncestorTarget, "source")); + File.WriteAllText(Path.Combine(linkedAncestorTarget, "source", "linked-ancestor-file.js"), "windows"); + Directory.CreateSymbolicLink(Path.Combine(testRoot, "linked-ancestor"), + Path.GetFullPath(linkedAncestorTarget)); + + AnalyzeCommand command = new(new AnalyzeOptions + { + SourcePath = new[] { sourcePath, directLinkedFile, directLinkedDirectory, sourceThroughLinkedAncestor }, + CustomRulesPath = testRulesPath, + IgnoreDefaultRules = true, + FollowSymlinks = followSymlinks + }, factory); + + var result = command.GetResult(); + + Assert.Equal(AnalyzeResult.ExitCode.Success, result.ResultCode); + Assert.Equal(expectedFiles, result.Metadata.TotalFiles); + } + finally + { + Directory.Delete(testRoot, true); + } + } + + [SymlinkFact] + public void SkipsDanglingSymlinksByDefault() + { + var testRoot = Path.Combine("TestOutput", $"DanglingSymlinkTest-{Guid.NewGuid()}"); + Directory.CreateDirectory(testRoot); + + try + { + File.WriteAllText(Path.Combine(testRoot, "regular.js"), "windows"); + var danglingLink = Path.Combine(testRoot, "dangling.js"); + File.CreateSymbolicLink(danglingLink, Path.Combine(testRoot, "missing.js")); + AnalyzeCommand command = new(new AnalyzeOptions + { + SourcePath = new[] { testRoot }, + CustomRulesPath = testRulesPath, + IgnoreDefaultRules = true + }, factory); + + var result = command.GetResult(); + + Assert.Equal(AnalyzeResult.ExitCode.Success, result.ResultCode); + Assert.Equal(1, result.Metadata.TotalFiles); + } + finally + { + Directory.Delete(testRoot, true); + } + } + [Fact] public async Task OverridesAsync() { diff --git a/AppInspector/Commands/AnalyzeCommand.cs b/AppInspector/Commands/AnalyzeCommand.cs index 095e4dbc..35cf5635 100644 --- a/AppInspector/Commands/AnalyzeCommand.cs +++ b/AppInspector/Commands/AnalyzeCommand.cs @@ -56,6 +56,10 @@ public class AnalyzeOptions /// public IEnumerable FilePathExclusions { get; set; } = Array.Empty(); /// + /// Follow symbolic links while enumerating source files. + /// + public bool FollowSymlinks { get; set; } + /// /// If enabled, processing will be performed on one file at a time. /// public bool SingleThread { get; set; } @@ -238,11 +242,23 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) { // Turn any relative paths into absolute paths for consistent behavior with file name regexes in rules var entryFullPath = Path.GetFullPath(entry); - if (Directory.Exists(entryFullPath)) + var directoryExists = Directory.Exists(entryFullPath); + var fileExists = File.Exists(entryFullPath); + if (directoryExists) { - _srcfileList.AddRange(Directory.EnumerateFiles(entryFullPath, "*.*", SearchOption.AllDirectories)); + _srcfileList.AddRange(Directory.EnumerateFiles(entryFullPath, "*.*", new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + MatchType = MatchType.Win32, +#if NETSTANDARD2_1 + AttributesToSkip = _options.FollowSymlinks ? 0 : FileAttributes.ReparsePoint +#else + AttributesToSkip = _options.FollowSymlinks ? FileAttributes.None : FileAttributes.ReparsePoint +#endif + })); } - else if (File.Exists(entryFullPath)) + else if (fileExists) { _srcfileList.Add(entryFullPath); } diff --git a/AppInspector/Commands/TagDiffCommand.cs b/AppInspector/Commands/TagDiffCommand.cs index e2f2599b..d211d977 100644 --- a/AppInspector/Commands/TagDiffCommand.cs +++ b/AppInspector/Commands/TagDiffCommand.cs @@ -24,6 +24,7 @@ public class TagDiffOptions public int ProcessingTimeOut { get; set; } public bool ScanUnknownTypes { get; set; } public bool SingleThread { get; set; } + public bool FollowSymlinks { get; set; } public IEnumerable ConfidenceFilters { get; set; } = new[] { Confidence.High, Confidence.Medium }; public IEnumerable SeverityFilters { get; set; } = new[] @@ -173,6 +174,7 @@ public TagDiffResult GetResult() NoShowProgress = true, ScanUnknownTypes = _options.ScanUnknownTypes, SingleThread = _options.SingleThread, + FollowSymlinks = _options.FollowSymlinks, CustomCommentsPath = _options.CustomCommentsPath, CustomLanguagesPath = _options.CustomLanguagesPath, DisableCustomRuleVerification = _options.DisableCustomRuleValidation, @@ -197,6 +199,7 @@ public TagDiffResult GetResult() NoShowProgress = true, ScanUnknownTypes = _options.ScanUnknownTypes, SingleThread = _options.SingleThread, + FollowSymlinks = _options.FollowSymlinks, CustomCommentsPath = _options.CustomCommentsPath, CustomLanguagesPath = _options.CustomLanguagesPath, DisableCustomRuleVerification = true, // Rules are already validated by the first command