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
108 changes: 108 additions & 0 deletions src/TaskAnalyzer.Tests/MultiThreadableTaskAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,114 @@ public override bool Execute()
// Safe wrapper recognition: Path.GetDirectoryName, Path.Combine, Path.GetFullPath
// ═══════════════════════════════════════════════════════════════════════

[Theory]
[InlineData("Path.GetDirectoryName(TargetFile)")]
[InlineData("Path.GetPathRoot(TargetFile)")]
[InlineData("System.IO.Path.GetDirectoryName(path: TargetFile)")]
[InlineData("IOPath.GetPathRoot(TargetFile)")]
[InlineData("(GetDirectoryName(TargetFile))!")]
public async Task InvertedPathExtraction_ProducesDiagnostic(string expression)
{
var source = $$"""
using System.IO;
using IOPath = System.IO.Path;
using static System.IO.Path;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public string TargetFile { get; set; } = "list.xml";
public override bool Execute()
{
Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath({{expression}}));
return true;
}
}
""";
var diags = await GetDiagnosticsAsync(source);

var diagnostic = diags.ShouldHaveSingleItem();
diagnostic.Id.ShouldBe(DiagnosticIds.ResolvePathBeforeExtraction);
source.Substring(diagnostic.Location.SourceSpan.Start, diagnostic.Location.SourceSpan.Length)
.ShouldBe($"TaskEnvironment.GetAbsolutePath({expression})");
}

[Theory]
[InlineData("GetDirectoryName")]
[InlineData("GetPathRoot")]
public async Task PathExtraction_AfterResolvingPath_NoDiagnostic(string method)
{
var diags = await GetDiagnosticsAsync($$"""
using System.IO;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public override bool Execute()
{
string dir = Path.{{method}}(TaskEnvironment.GetAbsolutePath("list.xml"));
Directory.CreateDirectory(dir);
return true;
}
}
""");

diags.ShouldBeEmpty();
}

[Theory]
[InlineData("all", "", 1)]
[InlineData("multithreadable_only", "", 0)]
[InlineData("multithreadable_only", ", IMultiThreadableTask", 1)]
public async Task InvertedPathExtraction_RespectsScope(string scope, string taskInterface, int expectedCount)
{
var diags = await GetDiagnosticsWithScopeAsync($$"""
using System.IO;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task{{taskInterface}}
{
public TaskEnvironment TaskEnvironment { get; set; }
public override bool Execute()
{
var dir = TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName("list.xml"));
return true;
}
}
""", scope);

diags.Length.ShouldBe(expectedCount);
diags.ShouldAllBe(d => d.Id == DiagnosticIds.ResolvePathBeforeExtraction);
}

[Fact]
public async Task PathExtraction_UnrelatedMethods_NoDiagnostic()
{
var diags = await GetDiagnosticsAsync("""
using System.IO;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public override bool Execute()
{
var a = TaskEnvironment.GetAbsolutePath(OtherPath.GetDirectoryName("list.xml"));
var b = TaskEnvironment.GetAbsolutePath(OtherPath.GetPathRoot("list.xml"));
var c = GetAbsolutePath(Path.GetDirectoryName("list.xml"));
var d = TaskEnvironment.GetAbsolutePath(Path.GetFileName("dir/list.xml"));
return true;
}
private static string GetAbsolutePath(string path) => path;
}
public static class OtherPath
{
public static string GetDirectoryName(string path) => path;
public static string GetPathRoot(string path) => path;
}
""");

diags.ShouldBeEmpty();
}

[Fact]
public async Task DirectoryCreate_WithGetDirectoryNameOfAbsolutePath_NoDiagnostic()
{
Expand Down
201 changes: 201 additions & 0 deletions src/TaskAnalyzer.Tests/MultiThreadableTaskCodeFixProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Xunit;
Expand Down Expand Up @@ -43,6 +44,7 @@ private static CSharpCodeFixTest<MultiThreadableTaskAnalyzer, MultiThreadableTas
DiagnosticIds.FilePathRequiresAbsolute => new DiagnosticResult(DiagnosticDescriptors.FilePathRequiresAbsolute),
DiagnosticIds.PotentialIssue => new DiagnosticResult(DiagnosticDescriptors.PotentialIssue),
DiagnosticIds.TransitiveUnsafeCall => new DiagnosticResult(DiagnosticDescriptors.TransitiveUnsafeCall),
DiagnosticIds.ResolvePathBeforeExtraction => new DiagnosticResult(DiagnosticDescriptors.ResolvePathBeforeExtraction),
_ => new DiagnosticResult(id, DiagnosticSeverity.Warning),
};

Expand Down Expand Up @@ -216,6 +218,205 @@ public override bool Execute()
.WithArguments("File.Exists(string?)", "wrap path argument with TaskEnvironment.GetAbsolutePath()")).RunAsync();
}

[Theory]
[InlineData("Path.GetDirectoryName(TargetFile)", "Path.GetDirectoryName(TaskEnvironment.GetAbsolutePath(TargetFile))")]
[InlineData("Path.GetPathRoot(TargetFile)", "Path.GetPathRoot(TaskEnvironment.GetAbsolutePath(TargetFile))")]
[InlineData("System.IO.Path.GetDirectoryName(TargetFile)", "System.IO.Path.GetDirectoryName(TaskEnvironment.GetAbsolutePath(TargetFile))")]
[InlineData("IOPath.GetPathRoot(TargetFile)", "IOPath.GetPathRoot(TaskEnvironment.GetAbsolutePath(TargetFile))")]
[InlineData("GetDirectoryName(TargetFile)", "GetDirectoryName(TaskEnvironment.GetAbsolutePath(TargetFile))")]
[InlineData("(Path.GetDirectoryName(path: TargetFile))!", "(Path.GetDirectoryName(path: TaskEnvironment.GetAbsolutePath(TargetFile)))!")]
[InlineData("Path.GetDirectoryName(/* file */ TargetFile)", "Path.GetDirectoryName(/* file */ TaskEnvironment.GetAbsolutePath(TargetFile))")]
[InlineData("Path.GetDirectoryName(Path.GetDirectoryName(TargetFile))", "Path.GetDirectoryName(Path.GetDirectoryName(TaskEnvironment.GetAbsolutePath(TargetFile)))")]
public async Task Fix_PathExtraction_WrapsOriginalPath(string expression, string fixedExpression)
{
var source = """
using System.IO;
using IOPath = System.IO.Path;
using static System.IO.Path;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public string TargetFile { get; set; } = "list.xml";
public override bool Execute()
{
REPLACE;
return true;
}
}
""";

await CreateFixTest(
source.Replace("REPLACE", "{|#0:Directory.CreateDirectory(path: " + expression + ")|}"),
source.Replace("REPLACE", "Directory.CreateDirectory(path: " + fixedExpression + ")"),
Diag(DiagnosticIds.FilePathRequiresAbsolute).WithLocation(0)
.WithArguments("Directory.CreateDirectory(string)", "wrap path argument with TaskEnvironment.GetAbsolutePath()")).RunAsync();
}

[Theory]
[InlineData("Path.GetDirectoryName(TargetFile)", "Path.GetDirectoryName(this.TaskEnvironment.GetAbsolutePath(path: TargetFile))", "GetDirectoryName")]
[InlineData("IOPath.GetPathRoot(path: TargetFile)", "IOPath.GetPathRoot(path: this.TaskEnvironment.GetAbsolutePath(path: TargetFile))", "GetPathRoot")]
[InlineData("(GetDirectoryName(TargetFile))!", "(GetDirectoryName(this.TaskEnvironment.GetAbsolutePath(path: TargetFile)))!", "GetDirectoryName")]
[InlineData("Path.GetDirectoryName(/* file */ TargetFile)", "Path.GetDirectoryName(/* file */ this.TaskEnvironment.GetAbsolutePath(path: TargetFile))", "GetDirectoryName")]
[InlineData("Path.GetDirectoryName(Path.GetDirectoryName(TargetFile))", "Path.GetDirectoryName(Path.GetDirectoryName(this.TaskEnvironment.GetAbsolutePath(path: TargetFile)))", "GetDirectoryName")]
public async Task Fix_InvertedPathExtraction_SwapsCalls(string expression, string fixedExpression, string method)
{
var source = """
using System.IO;
using IOPath = System.IO.Path;
using static System.IO.Path;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public string TargetFile { get; set; } = "list.xml";
public override bool Execute()
{
Directory.CreateDirectory(REPLACE);
return true;
}
}
""";

await CreateFixTest(
source.Replace("REPLACE", "{|#0:this.TaskEnvironment.GetAbsolutePath(path: " + expression + ")|}"),
source.Replace("REPLACE", fixedExpression),
Diag(DiagnosticIds.ResolvePathBeforeExtraction).WithLocation(0).WithArguments(method)).RunAsync();
}

[Theory]
[InlineData("GetDirectoryName")]
[InlineData("GetPathRoot")]
public async Task Fix_PathExtraction_LookalikeMethod_WrapsWholeExpression(string method)
{
var source = $$"""
using System.IO;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public override bool Execute()
{
REPLACE;
return true;
}
private static class Path
{
public static string {{method}}(string path) => path;
}
}
""";
var expression = $"Path.{method}(\"list.xml\")";

await CreateFixTest(
source.Replace("REPLACE", "{|#0:Directory.CreateDirectory(" + expression + ")|}"),
source.Replace("REPLACE", "Directory.CreateDirectory(TaskEnvironment.GetAbsolutePath(" + expression + "))"),
Diag(DiagnosticIds.FilePathRequiresAbsolute).WithLocation(0)
.WithArguments("Directory.CreateDirectory(string)", "wrap path argument with TaskEnvironment.GetAbsolutePath()")).RunAsync();
}

[Fact]
public async Task Fix_InvertedPathExtraction_AbsolutePathConsumers_NoFixOffered()
{
await CreateNoFixTest(
"""
using System.IO;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; }
public override bool Execute()
{
AbsolutePath dir = {|#0:TaskEnvironment.GetAbsolutePath(Path.GetDirectoryName("list.xml"))|};
var root = {|#1:TaskEnvironment.GetAbsolutePath(Path.GetPathRoot("list.xml"))|};
string value = root.Value;
return true;
}
}
""",
Diag(DiagnosticIds.ResolvePathBeforeExtraction).WithLocation(0).WithArguments("GetDirectoryName"),
Diag(DiagnosticIds.ResolvePathBeforeExtraction).WithLocation(1).WithArguments("GetPathRoot"));
}

[Fact]
public async Task Fix_InvertedPathExtraction_FixAllPreservesReceiver()
{
var source = """
using System.IO;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task
{
public override bool Execute() => true;
private static string Resolve(TaskEnvironment environment, string file)
{
string dir = DIRECTORY;
return ROOT;
}
}
""";
await CreateFixTest(
source.Replace("DIRECTORY", "{|#0:environment.GetAbsolutePath(Path.GetDirectoryName(file))|}")
.Replace("ROOT", "{|#1:environment.GetAbsolutePath(Path.GetPathRoot(file))|}"),
source.Replace("DIRECTORY", "Path.GetDirectoryName(environment.GetAbsolutePath(file))")
.Replace("ROOT", "Path.GetPathRoot(environment.GetAbsolutePath(file))"),
Diag(DiagnosticIds.ResolvePathBeforeExtraction).WithLocation(0).WithArguments("GetDirectoryName"),
Diag(DiagnosticIds.ResolvePathBeforeExtraction).WithLocation(1).WithArguments("GetPathRoot")).RunAsync();
}

[Theory]
[InlineData("GetDirectoryName", false, false)]
[InlineData("GetDirectoryName", true, false)]
[InlineData("GetPathRoot", false, false)]
[InlineData("GetPathRoot", true, false)]
[InlineData("GetDirectoryName", false, true)]
[InlineData("GetDirectoryName", true, true)]
[InlineData("GetPathRoot", false, true)]
[InlineData("GetPathRoot", true, true)]
public async Task Fix_PathExtraction_NullableInput(string method, bool inverted, bool suppressInput)
{
var source = """
using System.IO;
using Microsoft.Build.Framework;
public class MyTask : Microsoft.Build.Utilities.Task, IMultiThreadableTask
{
public TaskEnvironment TaskEnvironment { get; set; } = null!;
public string? TargetFile { get; set; }
public override bool Execute()
{
REPLACE;
return true;
}
}
""";
var input = suppressInput ? "TargetFile!" : "TargetFile";
var extraction = $"Path.{method}({input})!";
var original = inverted
? "Directory.CreateDirectory({|#0:TaskEnvironment.GetAbsolutePath(" + extraction + ")|})"
: "{|#0:Directory.CreateDirectory(" + extraction + ")|}";
var diagnostic = inverted
? Diag(DiagnosticIds.ResolvePathBeforeExtraction).WithLocation(0).WithArguments(method)
: Diag(DiagnosticIds.FilePathRequiresAbsolute).WithLocation(0)
.WithArguments("Directory.CreateDirectory(string)", "wrap path argument with TaskEnvironment.GetAbsolutePath()");
var fixedStatement = suppressInput
? $"Directory.CreateDirectory(Path.{method}(TaskEnvironment.GetAbsolutePath({input}))!)"
: original;
var test = CreateFixTest(source.Replace("REPLACE", original), source.Replace("REPLACE", fixedStatement), diagnostic);
if (!suppressInput)
{
test.FixedState.ExpectedDiagnostics.Add(diagnostic);
}

test.CompilerDiagnostics = CompilerDiagnostics.Warnings;
test.SolutionTransforms.Add((solution, projectId) =>
{
var project = solution.GetProject(projectId)!;
return solution.WithProjectCompilationOptions(projectId,
((CSharpCompilationOptions)project.CompilationOptions!).WithNullableContextOptions(NullableContextOptions.Enable))
.WithProjectParseOptions(projectId, project.ParseOptions!.WithDocumentationMode(DocumentationMode.Parse));
});
await test.RunAsync();
}

[Fact]
public async Task Fix_NewFileInfo_WrapsWithGetAbsolutePath()
{
Expand Down
1 change: 1 addition & 0 deletions src/TaskAnalyzer/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ MSBuildTask0011 | MSBuild.TaskAuthoring | Info | Prefer constructor injection fo
MSBuildTask0012 | MSBuild.TaskAuthoring | Warning | TaskEnvironment property is never assigned by MSBuild because the task does not implement IMultiThreadableTask
MSBuildTask0013 | MSBuild.TaskAuthoring | Info | Task declares IMultiThreadableTask but is not marked with [MSBuildMultiThreadableTask] (disabled by default)
MSBuildTask0014 | MSBuild.TaskAuthoring | Warning | [MSBuildMultiThreadableTask] applied to a type MSBuild never routes as a task -- not an ITask, or an abstract task whose attribute no subclass inherits -- where it has no effect
MSBuildTask0015 | MSBuild.TaskAuthoring | Warning | Resolve the original path before extracting its directory or root (code fix available)
12 changes: 11 additions & 1 deletion src/TaskAnalyzer/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ internal static class DiagnosticDescriptors
isEnabledByDefault: true,
description: "TaskRouter reads [MSBuildMultiThreadableTask] with inherit: false, off the concrete type the engine has just instantiated as a task. The attribute therefore only has an effect on a non-abstract class that implements ITask. On a type that is not a task, nothing ever reads it. On an abstract task, the engine never instantiates that type, and because the attribute is not inherited the concrete subclasses do not pick it up -- so every one of them is still routed to an out-of-proc TaskHost. Both shapes usually mean the attribute was applied to the wrong class: a helper type beside the real task, or a shared base instead of each task that derives from it.");

public static readonly DiagnosticDescriptor ResolvePathBeforeExtraction = new(
id: DiagnosticIds.ResolvePathBeforeExtraction,
title: "Resolve the path before extracting its directory or root",
messageFormat: "'Path.{0}' can return an empty or null path that GetAbsolutePath rejects; use Path.{0}(TaskEnvironment.GetAbsolutePath(...)) instead",
category: "MSBuild.TaskAuthoring",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "Resolve the original path through TaskEnvironment.GetAbsolutePath before calling Path.GetDirectoryName or Path.GetPathRoot, rather than resolving the extracted directory or root.");

public static ImmutableArray<DiagnosticDescriptor> All { get; } = ImmutableArray.Create(
CriticalError,
TaskEnvironmentRequired,
Expand All @@ -153,6 +162,7 @@ internal static class DiagnosticDescriptors
PreferTaskEnvironmentConstructorInjection,
TaskEnvironmentNeverAssigned,
MissingMultiThreadableTaskAttribute,
MultiThreadableTaskAttributeHasNoEffect);
MultiThreadableTaskAttributeHasNoEffect,
ResolvePathBeforeExtraction);
}
}
3 changes: 3 additions & 0 deletions src/TaskAnalyzer/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,8 @@ public static class DiagnosticIds

/// <summary>[MSBuildMultiThreadableTask] is applied to a type MSBuild never routes as a task, so it has no effect.</summary>
public const string MultiThreadableTaskAttributeHasNoEffect = "MSBuildTask0014";

/// <summary>Resolve the original path before extracting its directory or root.</summary>
public const string ResolvePathBeforeExtraction = "MSBuildTask0015";
}
}
Loading
Loading