Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add MSBuild binary log (.binlog) component detector #1250

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

brettfo
Copy link
Member

@brettfo brettfo commented Sep 19, 2024

Adds a new NuGet component detector to scan MSBuild binary log (*.binlog) files.

There was one major reason to add this: the current NuGet component detector that scans project.assets.json isn't always 100% accurate. This can happen when a given NuGet package has a transitive dependency on a System.* package, but the installed SDK has a newer version of that file that is replaced at build time.

One common example: a project has <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />. That package has a transitive dependency on System.Text.Json/6.0.0. No matter the SDK used to restore and build the project, the project.assets.json file will report that the package System.Text.Json/6.0.0 was used and implies that it'll be present at runtime. However, if the 6.0.424 SDK or newer was used to build, the System.Text.Json.dll file from the 6.0.0 package is removed and replaced with a newer System.Text.Json.dll from the SDK's runtime package.

To avoid reporting a false positive for a package that was replaced at build time (and therefore won't be present at runtime) the MSBuild binary log scanner was added.

If a binary log is generated with the /bl flag, every MSBuild event will be recorded. The new detector in this PR scans each event for the relevant AddItem and RemoveItem elements (corresponding to things like <SomeItemGroup Include="..." ... /> and <SomeItemGroup Remove="..." ... /> and uses that to build a 100% accurate dependency set.

Most of the content of this PR is to support the tests to make them as reliable as possible through 2 different means:

  1. Generate a fresh .binlog file for each test by running the appropriate dotnet build ... command. This ensures there are no large binary test assets added to this repo; they're simply generated as needed.
  2. Mock all necessary NuGet packages to prevent network access. To accomplish this, 3 common NuGet packages are faked for the tests: Microsoft.NETCore.App.Ref, Microsoft.AspNetCore.App.Ref, and Microsoft.WindowsDesktop.App.Ref.

Future work

Track packages implicitly added by the SDK when publishing an app. In a quick local test, the package Microsoft.NETCore.App.Host.win-x64 was added to the following item groups:

  • AppHostPack
  • SingleFileHostPack
  • ComHostPack
  • IjwHostPack
  • ResolvedAppHostPack
  • ResolvedSingleFileHostPack
  • ResolvedComHostPack
  • ResolvedJjwHostPack

To fully prevent reporting false positives, it would be a good idea to merge the binary log detector and the project.assets.json detector so that if a binary log is present and covers a given .csproj, only the binary log detector is used and fall back to the original detector otherwise. Without this work, the binary log detector will properly not report System.Text.Json/6.0.0, but the project.assets.json detector would, so the false positive will still appear.

@brettfo brettfo force-pushed the msbuild-binlog branch 3 times, most recently from 99ffda7 to 2f8d76a Compare September 19, 2024 18:40
Copy link

codecov bot commented Sep 19, 2024

Codecov Report

Attention: Patch coverage is 95.35284% with 27 lines in your changes missing coverage. Please review.

Project coverage is 89.0%. Comparing base (8360853) to head (eee567f).

Files with missing lines Patch % Lines
...tDetection.Detectors.Tests/MSBuildTestUtilities.cs 93.5% 11 Missing and 4 partials ⚠️
...rs/nuget/NuGetMSBuildBinaryLogComponentDetector.cs 94.5% 7 Missing and 2 partials ⚠️
...sts/NuGetMSBuildBinaryLogComponentDetectorTests.cs 98.2% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##            main   #1250     +/-   ##
=======================================
+ Coverage   88.9%   89.0%   +0.1%     
=======================================
  Files        359     362      +3     
  Lines      27672   28250    +578     
  Branches    1784    1836     +52     
=======================================
+ Hits       24613   25165    +552     
- Misses      2674    2695     +21     
- Partials     385     390      +5     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

if (!topLevelDependencies.TryGetValue(projectEvaluation.ProjectFile, out var topLevel))
{
topLevel = new(StringComparer.OrdinalIgnoreCase);
topLevelDependencies[projectEvaluation.ProjectFile] = topLevel;
Copy link
Member

Choose a reason for hiding this comment

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

one project file may be evaluated and built several times, you'll often see several evaluations per .csproj. Should we pick the best evaluation somehow or is it fine to do for each evaluation? Restore does an eval, then the build does another eval, and each target framework is a separate eval. We probably want a union of all evaluations?

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll experiment a bit to see if I can find inaccurate results, but generally we really only care about the item groups that NuGet populates like @(RuntimeCopyLocalItems) and I think that only happens during the Restore evaluation.

Copy link
Member

Choose a reason for hiding this comment

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

The restore evaluation is going to be pretty useless (it gets packages downloaded but doesn't produce any of the related items). You will definitely need to do all of the inner-build evaluations so that you get the superset of references, e.g.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFrameworks>net8.0;net472</TargetFrameworks>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3"
                      Condition=" '$(TargetFramework)' == 'net8.0' " />
    <PackageReference Include="System.Text.Json" Version="8.0.4"
                      Condition=" '$(TargetFramework)' == 'net472' " />
  </ItemGroup>

</Project>

topLevelDependencies[projectEvaluation.ProjectFile] = topLevel;
}

if (doRemoveOperation)
Copy link
Member

Choose a reason for hiding this comment

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

a bit worried that you might be getting additem/removeitem from different evaluations in an interleaved way, and since they key by the project path this might get confused.

On the other hand it shouldn't happen because the binlog is a tree, and you're walking the tree linearly, so in theory each evaluation should be processed sequentially one after another.

Copy link
Member Author

Choose a reason for hiding this comment

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

What might be an indication that the traversal is bad? If I try to process RemoveItem but the item isn't already present? Or is that something that MSBuild won't allow?

Copy link
Member

Choose a reason for hiding this comment

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

yes I guess, but as I said, maybe I'm just being paranoid

projectDependencies[packageName] = packageVersion;
}

project = project.GetNearestParent<Project>();
Copy link
Member

Choose a reason for hiding this comment

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

hmm, why are you walking up the project chain? I don't think these items flow to the calling project?

Copy link
Member Author

Choose a reason for hiding this comment

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

I found I needed this to track transitive dependencies. E.g., if library.csproj has <PackageReference Include="Some.Package" /> and unitTests.csproj has <ProjectReference Include="..\library\library.csproj" /> this will add Some.Package first to library.csproj and then crawl up the chain to unitTests.csproj so that the dependency is properly reported for both projects. The only oddity with this (and I may have to special case it) is the .sln file also appears in a Project node, but I do seem to be getting the proper hierarchy.

Copy link
Member

Choose a reason for hiding this comment

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

I don't think I understand this. unitTests.csproj should have Some.Package in its assets file and pull assets out of it in its ResolvePackageAssets. Can you share a log or project setup where this is necessary?

@KirillOsenkov
Copy link
Member

As an alternative, you could also see how binlogtool listnuget msbuild.binlog output.txt does it. It opens all embedded project.assets.json files, reads the contents into a model, and walks all of them to collect a flat list of all packages and versions used by the build.

I think your approach is fine too (?)

@jeffkl as an expert FYI, in case he wants to take a quick look

@KirillOsenkov
Copy link
Member

actually we could diff the results of binlogtool listnuget with these results, and see if there are any discrepancies. Would be like an oracle!

@brettfo
Copy link
Member Author

brettfo commented Sep 19, 2024

As an alternative, you could also see how binlogtool listnuget msbuild.binlog output.txt does it. It opens all embedded project.assets.json files, reads the contents into a model, and walks all of them to collect a flat list of all packages and versions used by the build.

I think your approach is fine too (?)

@jeffkl as an expert FYI, in case he wants to take a quick look

I explicitly don't want the contents of project.assets.json because they don't necessarily reflect what ends up in the output. There are more details in the original description, but in some cases the SDK will replace an assembly that NuGet has already resolved, so according to NuGet, a certain package ended up in the build, but in reality it's not there and shouldn't get reported.

@KirillOsenkov
Copy link
Member

I see, makes sense! 👍🏻

@brettfo
Copy link
Member Author

brettfo commented Sep 19, 2024

For those interested, I ran the package Microsoft.Extensions.Configuration.Json/6.0.0 through both the binlogtool suggested above and this PR. The results are as follows:

binlogtool:

Microsoft.Extensions.Configuration/6.0.0
Microsoft.Extensions.Configuration.Abstractions/6.0.0
Microsoft.Extensions.Configuration.FileExtensions/6.0.0
Microsoft.Extensions.Configuration.Json/6.0.0
Microsoft.Extensions.FileProviders.Abstractions/6.0.0
Microsoft.Extensions.FileProviders.Physical/6.0.0
Microsoft.Extensions.FileSystemGlobbing/6.0.0
Microsoft.Extensions.Primitives/6.0.0
System.Runtime.CompilerServices.Unsafe/6.0.0
System.Text.Encodings.Web/6.0.0
System.Text.Json/6.0.0

This PR:

Microsoft.Extensions.Configuration/6.0.0
Microsoft.Extensions.Configuration.Abstractions/6.0.0
Microsoft.Extensions.Configuration.FileExtensions/6.0.0
Microsoft.Extensions.Configuration.Json/6.0.0
Microsoft.Extensions.FileProviders.Abstractions/6.0.0
Microsoft.Extensions.FileProviders.Physical/6.0.0
Microsoft.Extensions.FileSystemGlobbing/6.0.0
Microsoft.Extensions.Primitives/6.0.0

The difference being that the SDK (6.0.425 on my machine) removed the System.* packages.

if (!topLevelDependencies.TryGetValue(projectEvaluation.ProjectFile, out var topLevel))
{
topLevel = new(StringComparer.OrdinalIgnoreCase);
topLevelDependencies[projectEvaluation.ProjectFile] = topLevel;
Copy link
Member

Choose a reason for hiding this comment

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

The restore evaluation is going to be pretty useless (it gets packages downloaded but doesn't produce any of the related items). You will definitely need to do all of the inner-build evaluations so that you get the superset of references, e.g.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFrameworks>net8.0;net472</TargetFrameworks>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3"
                      Condition=" '$(TargetFramework)' == 'net8.0' " />
    <PackageReference Include="System.Text.Json" Version="8.0.4"
                      Condition=" '$(TargetFramework)' == 'net472' " />
  </ItemGroup>

</Project>


if (doRemoveOperation)
{
topLevel.Remove(packageName);
Copy link
Member

Choose a reason for hiding this comment

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

Specifically I think this is going to cause problems if one TF of a project references a thing and the other gets it removed by framework unification.

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you have an example of this? I'd like to add a test to make sure we don't remove anything that shouldn't be removed.

projectDependencies[packageName] = packageVersion;
}

project = project.GetNearestParent<Project>();
Copy link
Member

Choose a reason for hiding this comment

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

I don't think I understand this. unitTests.csproj should have Some.Package in its assets file and pull assets out of it in its ResolvePackageAssets. Can you share a log or project setup where this is necessary?

<PackageVersion Include="MSTest.TestFramework" Version="3.5.1" />
<PackageVersion Include="MSTest.Analyzers" Version="3.5.1" />
<PackageVersion Include="MSTest.TestAdapter" Version="3.5.1" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.5.0" />
<PackageVersion Include="Microsoft.Build.Locator" Version="1.6.1" />
Copy link
Member

Choose a reason for hiding this comment

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

I have a concern here. Are you running as a standalone .NET 6 application currently? If so, you won't be able to load MSBuild from new SDKs. Have you tried on a machine that has only .NET 8 installed?

Copy link
Member Author

Choose a reason for hiding this comment

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

This seems to work in the unit tests which should only have .NET6 installed (it's installing here and if I understand that action correctly, it uses global.json to determine what to install, so no .NET 8 on the CI machine)

Copy link
Member

Choose a reason for hiding this comment

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

Right, the problem will be when you run the tool on a different machine which has only .NET 8 installed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants