Reproducible builds in .NET replace file paths with placeholder values to enable reproducible builds and prevent leaking local development paths in binary outputs. This is a common practice on CI/CD pipelines. However, libraries that rely on actual file paths to function, like snapshot testing frameworks, face a challenge: they need to know where to store and load snapshot files on disk. This post explores the problem and how Meziantou.Framework.SnapshotTesting and Meziantou.Framework.InlineSnapshotTesting handle this issue.
#What are Reproducible Builds?
Reproducible builds ensure that building the same source code with identical inputs produces byte-for-byte identical outputs. This is valuable for several reasons:
- Build Reproducibility: Verify that a binary was built from specific source code
- Security: Prevent accidental leaking of local development paths in compiled assemblies
- CI/CD Consistency: Ensure builds are identical across different machines and build environments
In .NET, MSBuild and Roslyn implement reproducible builds by replacing full file paths with a standardized placeholder. Instead of absolute paths like C:\Users\username\projects\myapp\src\file.cs, the compiler replaces them with reproducible placeholders like /_/src/file.cs.
#The Problem: Impact on File Paths
This path transformation is excellent for build reproducibility but creates challenges for libraries that depend on actual file paths at runtime. Consider this example:
C#
public class SnapshotHelper
{
public static void SaveSnapshot(object obj, [CallerFilePath] string filePath = "")
{
// With deterministic builds, filePath = "/_/tests/MyTests.cs"
// How do we find where to save the snapshot file on disk?
var snapshotPath = Path.ChangeExtension(filePath, ".snap");
File.WriteAllText(snapshotPath, JsonSerializer.Serialize(obj));
}
}
When deterministic builds are enabled, [CallerFilePath] returns /_/tests/MyTests.cs instead of the actual disk path. The snapshot testing library cannot write the snapshot file because it doesn't know where /_/ maps to on the actual filesystem.
This is a real issue for libraries like Meziantou.Framework.SnapshotTesting that store snapshots alongside test files and Meziantou.Framework.InlineSnapshotTesting which needs to update the test file itself.
#The MSBuild Solution
The solution is to register the source root location so that the /_/ prefix can be mapped back to the actual file system path at runtime.
When a project with reproducible builds is compiled, MSBuild generates a SourceRoot item that contains metadata about how source paths are mapped. This property is often set by SourceLink. Libraries can register this information to convert the reproducible paths back to actual file paths. The solution is to add a target in the project file that generates a C# source file with a module initializer to register the source root mapping at runtime:
XML
<Project>
<Target Name="_MeziantouGenerateSourceRootsCore">
<ItemGroup>
<_MeziantouSourceRoot Include="@(SourceRoot)"
Condition="'%(SourceRoot.NestedRoot)' == '' and '%(SourceRoot.MappedPath)' != ''">
<NormalizedIdentity>$([System.String]::Copy('%(Identity)').Replace('\', '/'))</NormalizedIdentity>
</_MeziantouSourceRoot>
</ItemGroup>
<!-- Create a C# file that registers the source roots and add it to the compilation -->
<ItemGroup>
<_MeziantouSourceRootRegistrationLines
Include="@(_MeziantouSourceRoot->' $(_MeziantouSourceRootRegistrationMethod)(%22%(MappedPath)%22, %22%(NormalizedIdentity)%22)%3B')" />
</ItemGroup>
<ItemGroup Condition="'@(_MeziantouSourceRootRegistrationLines)' != ''">
<_MeziantouSourceRootFileLines Include="using System.Runtime.CompilerServices%3B" />
<_MeziantouSourceRootFileLines Include="namespace $(_MeziantouSourceRootNamespace)%3B" />
<_MeziantouSourceRootFileLines Include="internal static class $(_MeziantouSourceRootInitializerClassName)" />
<_MeziantouSourceRootFileLines Include="{" />
<_MeziantouSourceRootFileLines Include=" [ModuleInitializer]" />
<_MeziantouSourceRootFileLines Include=" internal static void Initialize()" />
<_MeziantouSourceRootFileLines Include=" {" />
<_MeziantouSourceRootFileLines Include="@(_MeziantouSourceRootRegistrationLines)" />
<_MeziantouSourceRootFileLines Include=" }" />
<_MeziantouSourceRootFileLines Include="}" />
</ItemGroup>
<WriteLinesToFile
File="$(_MeziantouSourceRootFile)"
Lines="@(_MeziantouSourceRootFileLines)"
Overwrite="true"
WriteOnlyWhenDifferent="true"
Condition="'@(_MeziantouSourceRootFileLines)' != ''" />
<ItemGroup Condition="'@(_MeziantouSourceRootFileLines)' != ''">
<Compile Include="$(_MeziantouSourceRootFile)" />
</ItemGroup>
</Target>
</Project>
This will create file like this at build time:
C#
using System.Runtime.CompilerServices;
namespace Meziantou.Framework
{
internal static class SourceRootRegistration
{
[ModuleInitializer]
internal static void Initialize()
{
SourceRootResolver.Register("/_/", "C:/Users/username/projects/myapp/");
}
}
}
Then at runtime, the snapshot testing library can resolve /_/ back to C:/Users/username/projects/myapp/ and read/write snapshot files as expected. The SourceRootResolver is a simple utility class that maintains the mapping between reproducible paths and actual file system paths. When the library needs to read or write a snapshot file, it calls SourceRootResolver.ResolvePath("/_/tests/MyTests.cs") to get the actual path on disk.
#Conclusion
Reproducible builds are an important feature for modern .NET development, providing reproducibility, security, and verifiable builds. The trade-off is that libraries relying on file paths must account for the /_/ placeholder transformation.
MSBuild can generate the SourceRootRegistration class, which elegantly solves this by maintaining the mapping between reproducible paths and actual file system locations. If you're building a library that needs to access file paths at runtime, consider implementing this pattern to ensure compatibility with reproducible builds.
#Additional Resources
Do you have a question or a suggestion about this post? Contact me!