From 35738e9e09a536ac8166eb9d9ac93c2a9bcc51bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:26:16 +0000 Subject: [PATCH 1/9] Add symlink traversal option --- AppInspector.CLI/CLICmdOptions.cs | 4 ++ AppInspector.CLI/Program.cs | 2 + AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 40 +++++++++++++++++++ AppInspector/Commands/AnalyzeCommand.cs | 16 +++++++- AppInspector/Commands/TagDiffCommand.cs | 3 ++ 5 files changed, 64 insertions(+), 1 deletion(-) 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..acf0e4ec 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -134,6 +134,46 @@ public void Overrides() Assert.Equal(2, result.Metadata.UniqueMatchesCount); } + [Theory] + [InlineData(false, 1)] + [InlineData(true, 3)] + 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)); + + AnalyzeCommand command = new(new AnalyzeOptions + { + SourcePath = new[] { sourcePath }, + 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); + } + } + [Fact] public async Task OverridesAsync() { diff --git a/AppInspector/Commands/AnalyzeCommand.cs b/AppInspector/Commands/AnalyzeCommand.cs index 095e4dbc..aa3d1da8 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,9 +242,19 @@ 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 (!_options.FollowSymlinks && + (File.GetAttributes(entryFullPath) & FileAttributes.ReparsePoint) != 0) + { + continue; + } + if (Directory.Exists(entryFullPath)) { - _srcfileList.AddRange(Directory.EnumerateFiles(entryFullPath, "*.*", SearchOption.AllDirectories)); + _srcfileList.AddRange(Directory.EnumerateFiles(entryFullPath, "*.*", new EnumerationOptions + { + RecurseSubdirectories = true, + AttributesToSkip = _options.FollowSymlinks ? 0 : FileAttributes.ReparsePoint + })); } else if (File.Exists(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 From 2ef21b33ab77cb19fc2479b1d8147918f06875de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:27:21 +0000 Subject: [PATCH 2/9] Cover direct and nested symlinks --- AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 8 ++++++-- AppInspector/Commands/AnalyzeCommand.cs | 10 +++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index acf0e4ec..3afe9497 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -136,7 +136,7 @@ public void Overrides() [Theory] [InlineData(false, 1)] - [InlineData(true, 3)] + [InlineData(true, 5)] public void FollowSymlinks(bool followSymlinks, int expectedFiles) { var testRoot = Path.Combine("TestOutput", $"SymlinkTest-{Guid.NewGuid()}"); @@ -154,10 +154,14 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) 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)); AnalyzeCommand command = new(new AnalyzeOptions { - SourcePath = new[] { sourcePath }, + SourcePath = new[] { sourcePath, directLinkedFile, directLinkedDirectory }, CustomRulesPath = testRulesPath, IgnoreDefaultRules = true, FollowSymlinks = followSymlinks diff --git a/AppInspector/Commands/AnalyzeCommand.cs b/AppInspector/Commands/AnalyzeCommand.cs index aa3d1da8..c0020885 100644 --- a/AppInspector/Commands/AnalyzeCommand.cs +++ b/AppInspector/Commands/AnalyzeCommand.cs @@ -242,21 +242,25 @@ 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 (!_options.FollowSymlinks && + var directoryExists = Directory.Exists(entryFullPath); + var fileExists = File.Exists(entryFullPath); + if ((directoryExists || fileExists) && !_options.FollowSymlinks && (File.GetAttributes(entryFullPath) & FileAttributes.ReparsePoint) != 0) { continue; } - if (Directory.Exists(entryFullPath)) + if (directoryExists) { _srcfileList.AddRange(Directory.EnumerateFiles(entryFullPath, "*.*", new EnumerationOptions { RecurseSubdirectories = true, + IgnoreInaccessible = false, + MatchType = MatchType.Win32, AttributesToSkip = _options.FollowSymlinks ? 0 : FileAttributes.ReparsePoint })); } - else if (File.Exists(entryFullPath)) + else if (fileExists) { _srcfileList.Add(entryFullPath); } From b6dc4282b7571ee9c4be521b381eed6c824fcbee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:41:57 +0000 Subject: [PATCH 3/9] Handle dangling source symlinks --- AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 31 +++++++++++++++++++ AppInspector/Commands/AnalyzeCommand.cs | 19 ++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index 3afe9497..e22a0a0d 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -178,6 +178,37 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) } } + [Fact] + public void SkipsDanglingSymlinksByDefault() + { + var testRoot = Path.Combine("TestOutput", $"DanglingSymlinkTest-{Guid.NewGuid()}"); + Directory.CreateDirectory(testRoot); + + try + { + var regularFile = Path.Combine(testRoot, "regular.js"); + File.WriteAllText(regularFile, "windows"); + var danglingLink = Path.Combine(testRoot, "dangling.js"); + File.CreateSymbolicLink(danglingLink, Path.Combine(testRoot, "missing.js")); + + AnalyzeCommand command = new(new AnalyzeOptions + { + SourcePath = new[] { regularFile, danglingLink }, + 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 c0020885..57c96c01 100644 --- a/AppInspector/Commands/AnalyzeCommand.cs +++ b/AppInspector/Commands/AnalyzeCommand.cs @@ -244,8 +244,7 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) var entryFullPath = Path.GetFullPath(entry); var directoryExists = Directory.Exists(entryFullPath); var fileExists = File.Exists(entryFullPath); - if ((directoryExists || fileExists) && !_options.FollowSymlinks && - (File.GetAttributes(entryFullPath) & FileAttributes.ReparsePoint) != 0) + if (!_options.FollowSymlinks && IsReparsePoint(entryFullPath)) { continue; } @@ -270,6 +269,22 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) } } + static bool IsReparsePoint(string path) + { + try + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + } + if (_srcfileList.Count == 0) { throw new OpException(MsgHelp.FormatString(MsgHelp.ID.CMD_NO_FILES_IN_SOURCE, From c75cd1fdc394fb466cd92b46d979aa3fbfba26e0 Mon Sep 17 00:00:00 2001 From: Giulia Stocco <98900+gfs@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:00:25 -0700 Subject: [PATCH 4/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AppInspector/Commands/AnalyzeCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AppInspector/Commands/AnalyzeCommand.cs b/AppInspector/Commands/AnalyzeCommand.cs index 57c96c01..cd9ae236 100644 --- a/AppInspector/Commands/AnalyzeCommand.cs +++ b/AppInspector/Commands/AnalyzeCommand.cs @@ -256,7 +256,7 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) RecurseSubdirectories = true, IgnoreInaccessible = false, MatchType = MatchType.Win32, - AttributesToSkip = _options.FollowSymlinks ? 0 : FileAttributes.ReparsePoint + AttributesToSkip = _options.FollowSymlinks ? FileAttributes.None : FileAttributes.ReparsePoint })); } else if (fileExists) From 31f95b3f5e3eda4a64f996118555670c7b099fef Mon Sep 17 00:00:00 2001 From: Giulia Stocco <98900+gfs@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:00:53 -0700 Subject: [PATCH 5/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index e22a0a0d..69f4db05 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -189,8 +189,18 @@ public void SkipsDanglingSymlinksByDefault() var regularFile = Path.Combine(testRoot, "regular.js"); File.WriteAllText(regularFile, "windows"); var danglingLink = Path.Combine(testRoot, "dangling.js"); - File.CreateSymbolicLink(danglingLink, Path.Combine(testRoot, "missing.js")); - + try + { + File.CreateSymbolicLink(danglingLink, Path.Combine(testRoot, "missing.js")); + } + catch (UnauthorizedAccessException) + { + throw new Xunit.Sdk.SkipException("Symlink creation is not permitted on this machine."); + } + catch (PlatformNotSupportedException) + { + throw new Xunit.Sdk.SkipException("Symlinks are not supported on this platform."); + } AnalyzeCommand command = new(new AnalyzeOptions { SourcePath = new[] { regularFile, danglingLink }, From d65d023393cc9b418c73c7404ceb7297b2bc0490 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:19:49 +0000 Subject: [PATCH 6/9] Guard FollowSymlinks test against missing symlink privileges on Windows --- AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index 69f4db05..2483104b 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -151,9 +151,20 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) 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)); + try + { + File.CreateSymbolicLink(Path.Combine(sourcePath, "linked-file.js"), Path.GetFullPath(linkedFileTarget)); + Directory.CreateSymbolicLink(Path.Combine(sourcePath, "linked-directory"), + Path.GetFullPath(linkedDirectoryTarget)); + } + catch (UnauthorizedAccessException) + { + throw new Xunit.Sdk.SkipException("Symlink creation is not permitted on this machine."); + } + catch (PlatformNotSupportedException) + { + throw new Xunit.Sdk.SkipException("Symlinks are not supported on this platform."); + } var directLinkedFile = Path.Combine(testRoot, "direct-linked-file.js"); var directLinkedDirectory = Path.Combine(testRoot, "direct-linked-directory"); File.CreateSymbolicLink(directLinkedFile, Path.GetFullPath(linkedFileTarget)); From fd130b55293a01e9bd3d7585b9a43dea2d2cc2a1 Mon Sep 17 00:00:00 2001 From: Giulia Stocco <98900+gfs@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:57:13 +0000 Subject: [PATCH 7/9] Refactor exception handling in Analyze Command tests to use SkipException.ForSkip to fix build break. --- AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index 2483104b..094797c5 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -159,11 +159,11 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) } catch (UnauthorizedAccessException) { - throw new Xunit.Sdk.SkipException("Symlink creation is not permitted on this machine."); + throw Xunit.Sdk.SkipException.ForSkip("Symlink creation is not permitted on this machine."); } catch (PlatformNotSupportedException) { - throw new Xunit.Sdk.SkipException("Symlinks are not supported on this platform."); + throw Xunit.Sdk.SkipException.ForSkip("Symlinks are not supported on this platform."); } var directLinkedFile = Path.Combine(testRoot, "direct-linked-file.js"); var directLinkedDirectory = Path.Combine(testRoot, "direct-linked-directory"); @@ -206,11 +206,11 @@ public void SkipsDanglingSymlinksByDefault() } catch (UnauthorizedAccessException) { - throw new Xunit.Sdk.SkipException("Symlink creation is not permitted on this machine."); + throw Xunit.Sdk.SkipException.ForSkip("Symlink creation is not permitted on this machine."); } catch (PlatformNotSupportedException) { - throw new Xunit.Sdk.SkipException("Symlinks are not supported on this platform."); + throw Xunit.Sdk.SkipException.ForSkip("Symlinks are not supported on this platform."); } AnalyzeCommand command = new(new AnalyzeOptions { From aad874654dbb1f47d422bc56da19d7ca7e4d6b52 Mon Sep 17 00:00:00 2001 From: Giulia Stocco <98900+gfs@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:59:26 +0000 Subject: [PATCH 8/9] Enhance symlink handling in tests and update AnalyzeCommand to change default symlink behavior --- AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 110 ++++++++++++------ AppInspector/Commands/AnalyzeCommand.cs | 25 +--- 2 files changed, 80 insertions(+), 55 deletions(-) diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index 094797c5..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,9 +193,9 @@ public void Overrides() Assert.Equal(2, result.Metadata.UniqueMatchesCount); } - [Theory] - [InlineData(false, 1)] - [InlineData(true, 5)] + [SymlinkTheory] + [InlineData(false, 4)] + [InlineData(true, 6)] public void FollowSymlinks(bool followSymlinks, int expectedFiles) { var testRoot = Path.Combine("TestOutput", $"SymlinkTest-{Guid.NewGuid()}"); @@ -151,28 +210,23 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) var linkedFileTarget = Path.Combine(testRoot, "linked-file-target.js"); File.WriteAllText(linkedFileTarget, "windows"); File.WriteAllText(Path.Combine(linkedDirectoryTarget, "linked-directory-file.js"), "windows"); - try - { - File.CreateSymbolicLink(Path.Combine(sourcePath, "linked-file.js"), Path.GetFullPath(linkedFileTarget)); - Directory.CreateSymbolicLink(Path.Combine(sourcePath, "linked-directory"), - Path.GetFullPath(linkedDirectoryTarget)); - } - catch (UnauthorizedAccessException) - { - throw Xunit.Sdk.SkipException.ForSkip("Symlink creation is not permitted on this machine."); - } - catch (PlatformNotSupportedException) - { - throw Xunit.Sdk.SkipException.ForSkip("Symlinks are not supported on this platform."); - } + 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 }, + SourcePath = new[] { sourcePath, directLinkedFile, directLinkedDirectory, sourceThroughLinkedAncestor }, CustomRulesPath = testRulesPath, IgnoreDefaultRules = true, FollowSymlinks = followSymlinks @@ -189,7 +243,7 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) } } - [Fact] + [SymlinkFact] public void SkipsDanglingSymlinksByDefault() { var testRoot = Path.Combine("TestOutput", $"DanglingSymlinkTest-{Guid.NewGuid()}"); @@ -197,24 +251,12 @@ public void SkipsDanglingSymlinksByDefault() try { - var regularFile = Path.Combine(testRoot, "regular.js"); - File.WriteAllText(regularFile, "windows"); + File.WriteAllText(Path.Combine(testRoot, "regular.js"), "windows"); var danglingLink = Path.Combine(testRoot, "dangling.js"); - try - { - File.CreateSymbolicLink(danglingLink, Path.Combine(testRoot, "missing.js")); - } - catch (UnauthorizedAccessException) - { - throw Xunit.Sdk.SkipException.ForSkip("Symlink creation is not permitted on this machine."); - } - catch (PlatformNotSupportedException) - { - throw Xunit.Sdk.SkipException.ForSkip("Symlinks are not supported on this platform."); - } + File.CreateSymbolicLink(danglingLink, Path.Combine(testRoot, "missing.js")); AnalyzeCommand command = new(new AnalyzeOptions { - SourcePath = new[] { regularFile, danglingLink }, + SourcePath = new[] { testRoot }, CustomRulesPath = testRulesPath, IgnoreDefaultRules = true }, factory); diff --git a/AppInspector/Commands/AnalyzeCommand.cs b/AppInspector/Commands/AnalyzeCommand.cs index cd9ae236..35cf5635 100644 --- a/AppInspector/Commands/AnalyzeCommand.cs +++ b/AppInspector/Commands/AnalyzeCommand.cs @@ -244,11 +244,6 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) var entryFullPath = Path.GetFullPath(entry); var directoryExists = Directory.Exists(entryFullPath); var fileExists = File.Exists(entryFullPath); - if (!_options.FollowSymlinks && IsReparsePoint(entryFullPath)) - { - continue; - } - if (directoryExists) { _srcfileList.AddRange(Directory.EnumerateFiles(entryFullPath, "*.*", new EnumerationOptions @@ -256,7 +251,11 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) 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 (fileExists) @@ -269,22 +268,6 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) } } - static bool IsReparsePoint(string path) - { - try - { - return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; - } - catch (IOException) - { - return false; - } - catch (UnauthorizedAccessException) - { - return false; - } - } - if (_srcfileList.Count == 0) { throw new OpException(MsgHelp.FormatString(MsgHelp.ID.CMD_NO_FILES_IN_SOURCE, From 69fcb6ad646bcd82d3f3c0a88fe3f7e18f50b4fb Mon Sep 17 00:00:00 2001 From: Giulia Stocco <98900+gfs@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:16:24 -0700 Subject: [PATCH 9/9] Report skipped symlinks instead of dropping them silently (#649) Filtering with EnumerationOptions.AttributesToSkip discarded reparse points inside the runtime, so a scan that skipped symlinks had no way to say so: no FileRecord, no log line, and a lower file count than the source tree. Enumerate with FileSystemEnumerable and explicit predicates instead. Skipped files are recorded as ScanState.Skipped, which flows into filesSkipped in the report, and skipped directories are logged at Debug. FollowSymlinks keeps the previous Directory.EnumerateFiles path, so opting in is unchanged. Records go into MetaDataHelper.Files rather than Metadata.Files because PrepareReport replaces Metadata.Files from that bag. Also: - Document that a link named directly in SourcePath is always scanned, and that reparse-point filtering covers NTFS junctions and cloud placeholders. - Bump version 1.9 -> 1.10 for the AnalyzeOptions behavior change. - Drop the eager File.Exists and the unnecessary NETSTANDARD2_1 guard. - Move symlink test support to its own file, probe the directory the tests actually use, assert on file names, and cover TagDiff and CLI parsing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8fb7ba9b-6166-43dc-bfc8-8a5be3abfa08 --- AppInspector.CLI/CLICmdOptions.cs | 3 +- .../CLI/TestFollowSymlinksOption.cs | 55 +++++++ AppInspector.Tests/Commands/TestAnalyzeCmd.cs | 134 ++++++++---------- AppInspector.Tests/Commands/TestTagDiffCmd.cs | 48 +++++++ AppInspector.Tests/SymlinkTestSupport.cs | 118 +++++++++++++++ AppInspector/Commands/AnalyzeCommand.cs | 128 ++++++++++++++--- version.json | 2 +- 7 files changed, 399 insertions(+), 89 deletions(-) create mode 100644 AppInspector.Tests/CLI/TestFollowSymlinksOption.cs create mode 100644 AppInspector.Tests/SymlinkTestSupport.cs diff --git a/AppInspector.CLI/CLICmdOptions.cs b/AppInspector.CLI/CLICmdOptions.cs index d4d01208..5223f0ea 100644 --- a/AppInspector.CLI/CLICmdOptions.cs +++ b/AppInspector.CLI/CLICmdOptions.cs @@ -105,7 +105,8 @@ public record CLIAnalysisSharedCommandOptions : CLICustomRulesCommandOptions public bool ScanUnknownTypes { get; set; } [Option("follow-symlinks", Required = false, - HelpText = "Follow symbolic links while enumerating source files.")] + HelpText = + "Follow symbolic links found while traversing a source directory. Skipped links are reported as skipped files. A link named directly on the command line is always scanned. On Windows this option also applies to other reparse points, including NTFS junctions and cloud placeholder files such as OneDrive Files On-Demand.")] public bool FollowSymlinks { get; set; } [Option('c', "confidence-filters", Required = false, Separator = ',', diff --git a/AppInspector.Tests/CLI/TestFollowSymlinksOption.cs b/AppInspector.Tests/CLI/TestFollowSymlinksOption.cs new file mode 100644 index 00000000..1e397cf0 --- /dev/null +++ b/AppInspector.Tests/CLI/TestFollowSymlinksOption.cs @@ -0,0 +1,55 @@ +using System.Diagnostics.CodeAnalysis; +using CommandLine; +using Microsoft.ApplicationInspector.CLI; +using Xunit; + +namespace AppInspector.Tests.CLI; + +/// +/// Verifies that the symlink command line option is wired to the commands that accept a source path. +/// +[ExcludeFromCodeCoverage] +public class TestFollowSymlinksOption +{ + [Fact] + public void AnalyzeDefaultsToSkippingSymlinks() + { + var options = Parse("analyze", "-s", "sourcePath"); + + Assert.False(options.FollowSymlinks); + } + + [Fact] + public void AnalyzeAcceptsFollowSymlinks() + { + var options = Parse("analyze", "-s", "sourcePath", "--follow-symlinks"); + + Assert.True(options.FollowSymlinks); + } + + [Fact] + public void TagDiffDefaultsToSkippingSymlinks() + { + var options = Parse("tagdiff", "--src1", "one", "--src2", "two"); + + Assert.False(options.FollowSymlinks); + } + + [Fact] + public void TagDiffAcceptsFollowSymlinks() + { + var options = Parse("tagdiff", "--src1", "one", "--src2", "two", "--follow-symlinks"); + + Assert.True(options.FollowSymlinks); + } + + private static T Parse(params string[] args) where T : class + { + T? parsed = null; + Parser.Default.ParseArguments(args) + .WithParsed(options => parsed = options); + + Assert.NotNull(parsed); + return parsed!; + } +} diff --git a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs index 7ff86b2e..e2eb1644 100644 --- a/AppInspector.Tests/Commands/TestAnalyzeCmd.cs +++ b/AppInspector.Tests/Commands/TestAnalyzeCmd.cs @@ -16,65 +16,6 @@ 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() @@ -194,11 +135,11 @@ public void Overrides() } [SymlinkTheory] - [InlineData(false, 4)] - [InlineData(true, 6)] - public void FollowSymlinks(bool followSymlinks, int expectedFiles) + [InlineData(false)] + [InlineData(true)] + public void FollowSymlinks(bool followSymlinks) { - var testRoot = Path.Combine("TestOutput", $"SymlinkTest-{Guid.NewGuid()}"); + var testRoot = SymlinkTestSupport.CreateTestRoot("SymlinkTest"); var sourcePath = Path.Combine(testRoot, "source"); var linkedDirectoryTarget = Path.Combine(testRoot, "linked-directory-target"); Directory.CreateDirectory(sourcePath); @@ -206,17 +147,27 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) try { + // A plain file that is always scanned. File.WriteAllText(Path.Combine(sourcePath, "regular.js"), "windows"); + + // Link targets, deliberately outside the scanned source directory. var linkedFileTarget = Path.Combine(testRoot, "linked-file-target.js"); File.WriteAllText(linkedFileTarget, "windows"); File.WriteAllText(Path.Combine(linkedDirectoryTarget, "linked-directory-file.js"), "windows"); + + // Links reached by traversing the source directory. These are the ones the default skips. File.CreateSymbolicLink(Path.Combine(sourcePath, "linked-file.js"), Path.GetFullPath(linkedFileTarget)); Directory.CreateSymbolicLink(Path.Combine(sourcePath, "linked-directory"), Path.GetFullPath(linkedDirectoryTarget)); + + // Links named directly in SourcePath. These are always scanned, because refusing a path the caller + // asked for by name would be more surprising than following it. 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)); + + // A source path that reaches its directory through a linked ancestor. Also always scanned. var linkedAncestorTarget = Path.Combine(testRoot, "linked-ancestor-target"); var sourceThroughLinkedAncestor = Path.Combine(testRoot, "linked-ancestor", "source"); Directory.CreateDirectory(Path.Combine(linkedAncestorTarget, "source")); @@ -235,25 +186,55 @@ public void FollowSymlinks(bool followSymlinks, int expectedFiles) var result = command.GetResult(); Assert.Equal(AnalyzeResult.ExitCode.Success, result.ResultCode); - Assert.Equal(expectedFiles, result.Metadata.TotalFiles); + + var analyzed = FileNamesWithStatus(result, ScanState.Analyzed) + .Concat(FileNamesWithStatus(result, ScanState.Affected)).OrderBy(x => x).ToArray(); + var skipped = FileNamesWithStatus(result, ScanState.Skipped).OrderBy(x => x).ToArray(); + + if (followSymlinks) + { + Assert.Equal(new[] + { + "direct-linked-file.js", + "linked-ancestor-file.js", + // Reached twice: once through the directly named link and once through the traversed link. + "linked-directory-file.js", + "linked-directory-file.js", + "linked-file.js", + "regular.js" + }, analyzed); + Assert.Empty(skipped); + } + else + { + Assert.Equal(new[] + { + "direct-linked-file.js", + "linked-ancestor-file.js", + "linked-directory-file.js", + "regular.js" + }, analyzed); + + // The traversed file link is reported rather than silently dropped. The traversed directory link + // is not recursed into, so linked-directory-file.js is reached only via the directly named link. + Assert.Equal(new[] { "linked-file.js" }, skipped); + } } finally { - Directory.Delete(testRoot, true); + SymlinkTestSupport.TryDeleteTestRoot(testRoot); } } [SymlinkFact] public void SkipsDanglingSymlinksByDefault() { - var testRoot = Path.Combine("TestOutput", $"DanglingSymlinkTest-{Guid.NewGuid()}"); - Directory.CreateDirectory(testRoot); + var testRoot = SymlinkTestSupport.CreateTestRoot("DanglingSymlinkTest"); try { File.WriteAllText(Path.Combine(testRoot, "regular.js"), "windows"); - var danglingLink = Path.Combine(testRoot, "dangling.js"); - File.CreateSymbolicLink(danglingLink, Path.Combine(testRoot, "missing.js")); + File.CreateSymbolicLink(Path.Combine(testRoot, "dangling.js"), Path.Combine(testRoot, "missing.js")); AnalyzeCommand command = new(new AnalyzeOptions { SourcePath = new[] { testRoot }, @@ -264,14 +245,25 @@ public void SkipsDanglingSymlinksByDefault() var result = command.GetResult(); Assert.Equal(AnalyzeResult.ExitCode.Success, result.ResultCode); - Assert.Equal(1, result.Metadata.TotalFiles); + Assert.Equal(new[] { "regular.js" }, + FileNamesWithStatus(result, ScanState.Analyzed) + .Concat(FileNamesWithStatus(result, ScanState.Affected)).OrderBy(x => x).ToArray()); + + // A dangling link cannot be opened, so skipping it keeps it out of the error count. + Assert.Equal(new[] { "dangling.js" }, FileNamesWithStatus(result, ScanState.Skipped).ToArray()); + Assert.Empty(FileNamesWithStatus(result, ScanState.Error)); } finally { - Directory.Delete(testRoot, true); + SymlinkTestSupport.TryDeleteTestRoot(testRoot); } } + private static IEnumerable FileNamesWithStatus(AnalyzeResult result, ScanState status) + { + return result.Metadata.Files.Where(x => x.Status == status).Select(x => Path.GetFileName(x.FileName)); + } + [Fact] public async Task OverridesAsync() { diff --git a/AppInspector.Tests/Commands/TestTagDiffCmd.cs b/AppInspector.Tests/Commands/TestTagDiffCmd.cs index a65f8fae..dcb05a8f 100644 --- a/AppInspector.Tests/Commands/TestTagDiffCmd.cs +++ b/AppInspector.Tests/Commands/TestTagDiffCmd.cs @@ -107,4 +107,52 @@ public void NoDefaultNoCustomRules_Fail() var command = new TagDiffCommand(options, loggerFactory); Assert.Throws(() => command.GetResult()); } + + /// + /// TagDiff builds two AnalyzeOptions internally, so FollowSymlinks has to reach both of them. With the + /// option off, the linked copy of the source is not traversed and the two sides differ. + /// + [SymlinkTheory] + [InlineData(false, TagDiffResult.ExitCode.TestFailed)] + [InlineData(true, TagDiffResult.ExitCode.TestPassed)] + public void FollowSymlinksIsPassedToBothScans(bool followSymlinks, TagDiffResult.ExitCode expectedExitCode) + { + var testRoot = SymlinkTestSupport.CreateTestRoot("TagDiffSymlinkTest"); + + try + { + // Side 1 contains the Windows sample directly. + var sideOne = Path.Combine(testRoot, "side-one"); + Directory.CreateDirectory(sideOne); + File.Copy(testFileFourWindowsOneLinuxPath, Path.Combine(sideOne, "FourWindowsOneLinux.js")); + + // Side 2 reaches the same content only through a symbolic link. + var sideTwo = Path.Combine(testRoot, "side-two"); + var linkTarget = Path.Combine(testRoot, "link-target"); + Directory.CreateDirectory(sideTwo); + Directory.CreateDirectory(linkTarget); + File.Copy(testFileFourWindowsOneLinuxPath, Path.Combine(linkTarget, "FourWindowsOneLinux.js")); + File.WriteAllText(Path.Combine(sideTwo, "placeholder.txt"), string.Empty); + Directory.CreateSymbolicLink(Path.Combine(sideTwo, "linked"), Path.GetFullPath(linkTarget)); + + TagDiffOptions options = new() + { + SourcePath1 = new[] { sideOne }, + SourcePath2 = new[] { sideTwo }, + FilePathExclusions = Array.Empty(), //allow source under unittest path + IgnoreDefaultRules = true, + TestType = TagTestType.Equality, + CustomRulesPath = testRulesPath, + FollowSymlinks = followSymlinks + }; + + TagDiffCommand command = new(options, loggerFactory); + + Assert.Equal(expectedExitCode, command.GetResult().ResultCode); + } + finally + { + SymlinkTestSupport.TryDeleteTestRoot(testRoot); + } + } } \ No newline at end of file diff --git a/AppInspector.Tests/SymlinkTestSupport.cs b/AppInspector.Tests/SymlinkTestSupport.cs new file mode 100644 index 00000000..777b57fa --- /dev/null +++ b/AppInspector.Tests/SymlinkTestSupport.cs @@ -0,0 +1,118 @@ +using System; +using System.IO; +using Xunit; + +namespace AppInspector.Tests; + +/// +/// Detects whether the machine running the tests can create symbolic links. Creating a symbolic link on Windows +/// requires either Developer Mode or the SeCreateSymbolicLinkPrivilege, and some file systems do not support +/// links at all, so symlink tests are skipped rather than failed in those environments. +/// +internal static class SymlinkTestSupport +{ + private static readonly Lazy LazySkipReason = new(GetSkipReason); + + /// + /// The reason symlink tests cannot run here, or null when they can run. + /// + public static string? SkipReason => LazySkipReason.Value; + + /// + /// Creates a uniquely named directory for a symlink test under the same root the probe uses, so that the + /// skip decision and the test itself exercise the same file system. + /// + /// A short prefix describing the test, used in the directory name. + /// The path of the created directory. + public static string CreateTestRoot(string prefix) + { + var testRoot = Path.Combine(TestRootParent, $"{prefix}-{Guid.NewGuid()}"); + Directory.CreateDirectory(testRoot); + return testRoot; + } + + /// + /// Deletes a directory created by . Cleanup failures are ignored so that they + /// cannot replace a genuine assertion failure when called from a finally block. + /// + /// The directory to delete. + public static void TryDeleteTestRoot(string testRoot) + { + try + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, true); + } + } + catch (Exception) + { + // Cleanup is best effort. Leaving a directory behind in TestOutput must not mask a test result. + } + } + + // The tests write beneath the working directory, not the system temp directory, and the two can be different + // file systems with different symlink support. Probing here keeps the skip decision accurate. + private static string TestRootParent => "TestOutput"; + + private static string? GetSkipReason() + { + string testRoot; + + try + { + Directory.CreateDirectory(TestRootParent); + testRoot = Path.Combine(TestRootParent, $"SymlinkProbe-{Guid.NewGuid()}"); + Directory.CreateDirectory(testRoot); + } + catch (Exception ex) + { + return $"Could not create a directory to probe for symlink support: {ex.GetType().Name}: {ex.Message}"; + } + + try + { + 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 (Exception ex) + { + // Catching broadly on purpose: this runs from a lazy initializer, and an unexpected exception type + // would otherwise surface as an opaque TypeInitializationException instead of a readable skip reason. + return $"Symlink creation is not available here: {ex.GetType().Name}: {ex.Message}"; + } + finally + { + TryDeleteTestRoot(testRoot); + } + } +} + +/// +/// A that skips when the machine cannot create symbolic links. +/// +[AttributeUsage(AttributeTargets.Method)] +internal sealed class SymlinkFactAttribute : FactAttribute +{ + public SymlinkFactAttribute() + { + Skip = SymlinkTestSupport.SkipReason; + } +} + +/// +/// A that skips when the machine cannot create symbolic links. +/// +[AttributeUsage(AttributeTargets.Method)] +internal sealed class SymlinkTheoryAttribute : TheoryAttribute +{ + public SymlinkTheoryAttribute() + { + Skip = SymlinkTestSupport.SkipReason; + } +} diff --git a/AppInspector/Commands/AnalyzeCommand.cs b/AppInspector/Commands/AnalyzeCommand.cs index 35cf5635..f10d88ea 100644 --- a/AppInspector/Commands/AnalyzeCommand.cs +++ b/AppInspector/Commands/AnalyzeCommand.cs @@ -8,6 +8,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using System.IO.Enumeration; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -56,7 +57,11 @@ public class AnalyzeOptions /// public IEnumerable FilePathExclusions { get; set; } = Array.Empty(); /// - /// Follow symbolic links while enumerating source files. + /// Follow symbolic links while enumerating source files. When false (the default), symbolic links found + /// while traversing a source directory are skipped and recorded as skipped in the scan metadata. A link + /// named directly in is always scanned regardless of this setting. + /// On Windows this also applies to other reparse points, including NTFS junctions and cloud placeholder + /// files such as OneDrive Files On-Demand, which are skipped rather than rehydrated. /// public bool FollowSymlinks { get; set; } /// @@ -198,6 +203,21 @@ public AnalyzeResult() public class AnalyzeCommand { private const int ProgressBarUpdateDelay = 100; + + /// + /// Mirrors the behavior of : recurse everywhere, surface + /// inaccessible entries instead of hiding them, and do not filter on file attributes. Reparse points are + /// handled by the predicates in rather than by + /// , so that skipped entries can still be reported. + /// + private static readonly EnumerationOptions SourceEnumerationOptions = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + MatchType = MatchType.Win32, + AttributesToSkip = 0 + }; + private readonly Confidence _confidence = Confidence.Unspecified; private readonly List _fileExclusionList = new(); @@ -207,6 +227,7 @@ public class AnalyzeCommand private readonly Severity _severity = Severity.Unspecified; private readonly List _srcfileList = new(); private readonly Languages _languages = new(); + private int _skippedLinkCount; private MetaDataHelper _metaDataHelper; //wrapper containing MetaData object to be assigned to result private readonly RuleProcessor _rulesProcessor; @@ -242,23 +263,14 @@ 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); - var directoryExists = Directory.Exists(entryFullPath); - var fileExists = File.Exists(entryFullPath); - if (directoryExists) + // Directory.Exists and File.Exists resolve links, so a link named directly in SourcePath is always + // scanned, even when FollowSymlinks is false. Refusing to scan a path the caller asked for by name + // would be more surprising than following it; only links reached by traversal are skipped. + if (Directory.Exists(entryFullPath)) { - _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 - })); + _srcfileList.AddRange(EnumerateSourceFiles(entryFullPath)); } - else if (fileExists) + else if (File.Exists(entryFullPath)) { _srcfileList.Add(entryFullPath); } @@ -270,6 +282,13 @@ public AnalyzeCommand(AnalyzeOptions opt, ILoggerFactory? loggerFactory = null) if (_srcfileList.Count == 0) { + if (_skippedLinkCount > 0) + { + _logger.LogWarning( + "No files to scan. {Count} symbolic link(s) or reparse point(s) were skipped; enable FollowSymlinks to include them.", + _skippedLinkCount); + } + throw new OpException(MsgHelp.FormatString(MsgHelp.ID.CMD_NO_FILES_IN_SOURCE, string.Join(',', _options.SourcePath))); } @@ -366,6 +385,83 @@ void VerifyFile(string filename) _rulesProcessor = new RuleProcessor(rulesSet, rpo); } + /// + /// Enumerates every file beneath , skipping reparse points unless + /// is set. + /// + /// + /// Filtering with would discard the entries inside the + /// runtime, leaving the scan unable to report what it skipped. Enumerating with explicit predicates keeps + /// the skipped entries observable: skipped files are recorded as and + /// skipped directories are logged. + /// + /// The absolute path of the directory to enumerate. + /// The absolute paths of the files to scan. + private List EnumerateSourceFiles(string directoryFullPath) + { + if (_options.FollowSymlinks) + { + return Directory.EnumerateFiles(directoryFullPath, "*.*", SourceEnumerationOptions).ToList(); + } + + // A "*.*" Win32 pattern matches every name, including names without a dot, so the enumerable applies no + // name filter and relies on the predicates below instead. + FileSystemEnumerable enumerable = new(directoryFullPath, + static (ref FileSystemEntry entry) => entry.ToFullPath(), + SourceEnumerationOptions) + { + ShouldIncludePredicate = (ref FileSystemEntry entry) => + { + if (entry.IsDirectory) + { + return false; + } + + if (!IsReparsePoint(ref entry)) + { + return true; + } + + var fullPath = entry.ToFullPath(); + _logger.LogDebug( + "File skipped: symbolic link or other reparse point. Enable FollowSymlinks to scan it. {Path}", + fullPath); + // Added to the helper's bag rather than Metadata.Files, because Metadata.Files is replaced from + // the bag when the report is prepared. + _metaDataHelper.Files.Add(new FileRecord { FileName = fullPath, Status = ScanState.Skipped }); + _skippedLinkCount++; + return false; + }, + ShouldRecursePredicate = (ref FileSystemEntry entry) => + { + if (!IsReparsePoint(ref entry)) + { + return true; + } + + _logger.LogDebug( + "Directory not traversed: symbolic link or other reparse point. Enable FollowSymlinks to traverse it. {Path}", + entry.ToFullPath()); + _skippedLinkCount++; + return false; + } + }; + + // Materialized here because the predicates above log and mutate metadata, so the enumerable must not be + // walked more than once. + return enumerable.ToList(); + } + + /// + /// Checks whether an entry is a reparse point. On Windows this covers NTFS junctions and cloud placeholder + /// files (for example OneDrive Files On-Demand) in addition to symbolic links, all of which are skipped by + /// default so that a scan does not traverse outside the source tree or rehydrate remote content. + /// + private static bool IsReparsePoint(ref FileSystemEntry entry) + { + return (entry.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + } + private DateTime DateScanned { get; } /// diff --git a/version.json b/version.json index 67e93cb6..1a993c5b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.9", + "version": "1.10", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$"