diff --git a/.azuredevops/pipelines/pr-ci.yml b/.azuredevops/pipelines/pr-ci.yml
index d2243db..eec4da5 100644
--- a/.azuredevops/pipelines/pr-ci.yml
+++ b/.azuredevops/pipelines/pr-ci.yml
@@ -181,6 +181,9 @@ jobs:
parameters:
RepoRoot: $(Build.SourcesDirectory)/MSBuildCache
MSBuildPath: $(MSBuildPath)
+ # This job builds MSBuild from tip of main, which carries the enumeration pattern, so the
+ # scenarios must actually run here rather than skip.
+ RequireEnumerationCapability: true
- publish: $(LogDirectory)
displayName: Publish Logs
diff --git a/.azuredevops/pipelines/templates/e2e-test.yml b/.azuredevops/pipelines/templates/e2e-test.yml
index 340d8ab..880dd28 100644
--- a/.azuredevops/pipelines/templates/e2e-test.yml
+++ b/.azuredevops/pipelines/templates/e2e-test.yml
@@ -4,22 +4,34 @@ parameters:
- name: MSBuildPath
type: string
default: ""
+# Set by jobs whose MSBuild is expected to report FileAccessData.EnumeratePattern, so that a
+# missing capability fails instead of silently skipping the scenarios.
+- name: RequireEnumerationCapability
+ type: boolean
+ default: false
steps:
- task: PowerShell@2
- displayName: "E2E Test: Microsoft.MSBuildCache.Local"
+ displayName: "E2E Smoke: Microsoft.MSBuildCache.Local"
inputs:
- filePath: ${{ parameters.RepoRoot }}\tests\test.ps1
+ filePath: ${{ parameters.RepoRoot }}\tests\smoke.ps1
arguments: -MSBuildPath "${{ parameters.MSBuildPath }}" -Configuration $(BuildConfiguration) -LogDirectory "$(LogDirectory)\Tests\Local" -LocalPackageDir "$(Pipeline.Workspace)\artifacts\$(BuildConfiguration)\packages" -CachePackage Microsoft.MSBuildCache.Local
pwsh: true
- task: PowerShell@2
- displayName: "E2E Test: Microsoft.MSBuildCache.AzurePipelines"
+ displayName: "E2E Scenarios: probe and enumeration fingerprinting"
+ inputs:
+ filePath: ${{ parameters.RepoRoot }}\tests\scenarios.ps1
+ arguments: -MSBuildPath "${{ parameters.MSBuildPath }}" -Configuration $(BuildConfiguration) -LogDirectory "$(LogDirectory)\Scenarios" -LocalPackageDir "$(Pipeline.Workspace)\artifacts\$(BuildConfiguration)\packages" -CachePackage Microsoft.MSBuildCache.Local -RequireEnumerationCapability $${{ parameters.RequireEnumerationCapability }}
+ pwsh: true
+
+- task: PowerShell@2
+ displayName: "E2E Smoke: Microsoft.MSBuildCache.AzurePipelines"
# The access token from forks do not have enough scopes to access the pipeline cache, so skip these tests.
# Note to repo maintainers: You can manually run the pipeline against the commit, even if the commit is from a fork, if you wish to test this.
condition: ne(variables['System.PullRequest.IsFork'], 'True')
inputs:
- filePath: ${{ parameters.RepoRoot }}\tests\test.ps1
+ filePath: ${{ parameters.RepoRoot }}\tests\smoke.ps1
arguments: -MSBuildPath "${{ parameters.MSBuildPath }}" -Configuration $(BuildConfiguration) -LogDirectory "$(LogDirectory)\Tests\AzurePipelines" -LocalPackageDir "$(Pipeline.Workspace)\artifacts\$(BuildConfiguration)\packages" -CachePackage Microsoft.MSBuildCache.AzurePipelines
pwsh: true
env:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bdf4582..e840515 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -56,8 +56,6 @@ Finally, add a `PackageReference` to MSBuildCache to your test repo with version
**NOTE!** Because you're using a locally built package, you may need to clear it from your package cache after each iteration via a command like `rmdir /S /Q %NUGET_PACKAGES%\Microsoft.MSBuildCache` (if you set `%NUGET_PACKAGES%`) or `rmdir /S /Q %USERPROFILE%\.nuget\packages\Microsoft.MSBuildCache` if you're using the dfault package cache location. Additionally, to ensure you're not using the head version of the package, you may need to create a branch and dummy commit locally to ensure the version is higher.
-**NOTE!** MSBuildCache currently does not handle incremental builds well! The current target scenario is for CI environments, so **it's expected that the repo is always clean before building**. The main reason for this gap is because file probes and directory enumerations are not currently considered.
-
To enable file reporting via detours in MSBuild, ensure `/graph` and `/reportfileaccesses` are used.
Example of a set of commands to test MSBuildCache e2e in some repo:
diff --git a/README.md b/README.md
index 538f4d9..1875b49 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,6 @@
This project provides plugin implementations for the experimental [MSBuild Project Cache](https://github.com/dotnet/msbuild/blob/main/documentation/specs/project-cache.md), which enables project-level caching within MSBuild.
-> [!IMPORTANT]
-> Currently MSBuildCache assumes that the build is running in a clean repo. Incremental builds, e.g. local developer builds, are not supported. Target scenarios include PR builds and CI builds.
-
## Usage
This feature requires Visual Studio 17.9 or later.
@@ -76,6 +73,7 @@ These settings are common across all plugins, although different implementations
| `$(MSBuildCacheSkipUnchangedOutputFiles)` | `bool` | false | Whether to avoid writing output files on cache hit if the file is unchanged, which can improve performance for incremental builds. A file is considered unchanged if it exists, the previously placed file and file to be placed have the same hash, and the the previously placed file and current file on disk have the same timestamp and file size. |
| `$(MSBuildCacheTouchOutputFiles)` | `bool` | false | Whether to update the last write time for output files on cache hit. All files for a given cache entry will have the same timestamp. Note that outputs which skip materialization via `MSBuildCacheSkipUnchangedOutputFiles` are still touched. |
| `$(MSBuildCacheIgnoreDotNetSdkPatchVersion)` | `bool` | false | Whether to ignore the patch version when doing cache lookups. This trades off some correctness for the sake of getting cache hits when the SDK version isn't exactly the same. The default behavior is to consider the exact SDK version, eg. "8.0.404". With this setting set to true, it will instead use something like "8.0.4XX". Note that the major version, minor version, and feature bands are still considered. |
+| `$(MSBuildCacheEnableProbeAndEnumerationFingerprinting)` | `bool` | true | Whether file probes (existence checks) and directory enumerations contribute to the strong fingerprint, enabling correct caching for incremental builds — including cases where MSBuild source globs match different files. Requires an MSBuild that reports directory enumeration patterns; on older versions this is forced to `false` and a message is logged. |
When configuring settings which are list types, you should always append to the existing value to avoid overriding the defaults:
diff --git a/src/Common.Tests/ByRefGetterFactoryTests.cs b/src/Common.Tests/ByRefGetterFactoryTests.cs
new file mode 100644
index 0000000..9ab9069
--- /dev/null
+++ b/src/Common.Tests/ByRefGetterFactoryTests.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.MSBuildCache.FileAccess;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.MSBuildCache.Tests;
+
+///
+/// Covers binding against both shapes of MSBuild's FileAccessData: the older one that lacks
+/// the enumeration fields and the newer one that carries them. The real struct comes from whichever
+/// Microsoft.Build.dll the host supplies, so stand-in structs are used to exercise both sides
+/// without needing two MSBuild installations.
+///
+[TestClass]
+public sealed class ByRefGetterFactoryTests
+{
+ private enum StandInAttributes : uint
+ {
+ None = 0,
+ Directory = 0x10,
+ }
+
+ /// Mirrors the pre-18.10 FileAccessData: no enumeration fields.
+ private struct OlderFileAccessData
+ {
+ private string _path;
+
+ public OlderFileAccessData(string path) => _path = path;
+
+ public string Path
+ {
+ readonly get => _path;
+ private set => _path = value;
+ }
+ }
+
+ /// Mirrors the newer FileAccessData, including the readonly-get/private-set shape.
+ private struct NewerFileAccessData
+ {
+ private string _path;
+ private string? _enumeratePattern;
+ private StandInAttributes _openedAttributes;
+
+ public NewerFileAccessData(string path, string? enumeratePattern, StandInAttributes openedAttributes)
+ {
+ _path = path;
+ _enumeratePattern = enumeratePattern;
+ _openedAttributes = openedAttributes;
+ }
+
+ public string Path
+ {
+ readonly get => _path;
+ private set => _path = value;
+ }
+
+ public string? EnumeratePattern
+ {
+ readonly get => _enumeratePattern;
+ private set => _enumeratePattern = value;
+ }
+
+ public StandInAttributes OpenedFileOrDirectoryAttributes
+ {
+ readonly get => _openedAttributes;
+ private set => _openedAttributes = value;
+ }
+ }
+
+ [TestMethod]
+ public void ReturnsNullWhenPropertyIsAbsent()
+ {
+ Assert.IsNull(ByRefGetterFactory.TryCreate("EnumeratePattern"));
+ Assert.IsNull(ByRefGetterFactory.TryCreate("OpenedFileOrDirectoryAttributes"));
+ }
+
+ [TestMethod]
+ public void ReturnsNullWhenPropertyTypeDiffers()
+ {
+ // Guards against silently binding to a property that was reshaped rather than added.
+ Assert.IsNull(ByRefGetterFactory.TryCreate("EnumeratePattern"));
+ }
+
+ [TestMethod]
+ public void ReadsStringPropertyWithoutBoxing()
+ {
+ ByRefGetter? getter =
+ ByRefGetterFactory.TryCreate("EnumeratePattern");
+ Assert.IsNotNull(getter);
+
+ NewerFileAccessData data = new(@"X:\dir", "*.cs", StandInAttributes.Directory);
+ Assert.AreEqual("*.cs", getter(ref data));
+ }
+
+ [TestMethod]
+ public void ReadsEnumProperty()
+ {
+ ByRefGetter? getter =
+ ByRefGetterFactory.TryCreate("OpenedFileOrDirectoryAttributes");
+ Assert.IsNotNull(getter);
+
+ NewerFileAccessData data = new(@"X:\dir", "*.cs", StandInAttributes.Directory);
+ Assert.AreEqual(StandInAttributes.Directory, getter(ref data));
+ }
+
+ [TestMethod]
+ public void ReadsNullStringProperty()
+ {
+ ByRefGetter? getter =
+ ByRefGetterFactory.TryCreate("EnumeratePattern");
+ Assert.IsNotNull(getter);
+
+ NewerFileAccessData data = new(@"X:\dir", enumeratePattern: null, StandInAttributes.None);
+ Assert.IsNull(getter(ref data));
+ }
+
+ ///
+ /// The getter is bound once and reused across every reported file access, so it must observe the
+ /// instance it is handed rather than a snapshot captured at bind time.
+ ///
+ [TestMethod]
+ public void BoundGetterIsReusableAcrossInstances()
+ {
+ ByRefGetter? getter =
+ ByRefGetterFactory.TryCreate("EnumeratePattern");
+ Assert.IsNotNull(getter);
+
+ NewerFileAccessData first = new(@"X:\a", "*.cs", StandInAttributes.None);
+ NewerFileAccessData second = new(@"X:\b", "*.dll", StandInAttributes.None);
+
+ Assert.AreEqual("*.cs", getter(ref first));
+ Assert.AreEqual("*.dll", getter(ref second));
+ }
+}
diff --git a/src/Common.Tests/FingerprintFactoryTests.cs b/src/Common.Tests/FingerprintFactoryTests.cs
new file mode 100644
index 0000000..7c008c6
--- /dev/null
+++ b/src/Common.Tests/FingerprintFactoryTests.cs
@@ -0,0 +1,1294 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+using BuildXL.Cache.ContentStore.Hashing;
+using DotNet.Globbing;
+using Microsoft.MSBuildCache.FileAccess;
+using Microsoft.MSBuildCache.Fingerprinting;
+using Microsoft.MSBuildCache.Hashing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.MSBuildCache.Tests;
+
+[TestClass]
+public class FingerprintFactoryTests
+{
+ ///
+ /// Verifies that toggling changes the
+ /// plugin-settings fingerprint entries that fold into the weak fingerprint. This is the cache-entry
+ /// segregation mechanism: flag-on and flag-off builds end up under different weak fingerprints and therefore
+ /// don't pollute each other's selectors.
+ ///
+ [TestMethod]
+ public void WeakFingerprintFlagSegregation()
+ {
+ using IContentHasher contentHasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ IInputHasher inputHasher = new NoopInputHasher();
+ var pathNormalizer = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+
+ PluginSettings off = new TestPluginSettings { RepoRoot = @"X:\Repo", EnableProbeAndEnumerationFingerprinting = false };
+ PluginSettings on = new TestPluginSettings { RepoRoot = @"X:\Repo", EnableProbeAndEnumerationFingerprinting = true };
+
+ var factoryOff = new FingerprintFactory(contentHasher, inputHasher, off, pathNormalizer);
+ var factoryOn = new FingerprintFactory(contentHasher, inputHasher, on, pathNormalizer);
+
+ List entriesOff = GetPluginSettingsFingerprintEntries(factoryOff);
+ List entriesOn = GetPluginSettingsFingerprintEntries(factoryOn);
+
+ // The two collections must differ — specifically, exactly one entry differs (the flag value entry).
+ FingerprintEntry offFlagEntry = entriesOff.Single(e => e.Description.Contains(nameof(PluginSettings.EnableProbeAndEnumerationFingerprinting), StringComparison.Ordinal));
+ FingerprintEntry onFlagEntry = entriesOn.Single(e => e.Description.Contains(nameof(PluginSettings.EnableProbeAndEnumerationFingerprinting), StringComparison.Ordinal));
+
+ Assert.AreNotEqual(offFlagEntry.Description, onFlagEntry.Description, "Flag descriptions should differ.");
+ CollectionAssert.AreNotEqual(offFlagEntry.Hash, onFlagEntry.Hash, "Flag entry hashes should differ.");
+
+ // And no other entry should differ between the two factories — the flag is the only setting toggled.
+ List commonOff = entriesOff
+ .Where(e => !e.Description.Contains(nameof(PluginSettings.EnableProbeAndEnumerationFingerprinting), StringComparison.Ordinal))
+ .Select(e => e.Description).ToList();
+ List commonOn = entriesOn
+ .Where(e => !e.Description.Contains(nameof(PluginSettings.EnableProbeAndEnumerationFingerprinting), StringComparison.Ordinal))
+ .Select(e => e.Description).ToList();
+ CollectionAssert.AreEqual(commonOff, commonOn);
+ }
+
+ ///
+ /// Sentinels are derived from the configured so they're the right
+ /// byte length for the active . Two factory instances using the same hash type must
+ /// produce bit-identical sentinels — that's the cross-machine stability guarantee that lets the type-aware
+ /// strong fingerprint be reproducible.
+ ///
+ [TestMethod]
+ [DataRow(HashType.Murmur)]
+ [DataRow(HashType.Vso0)]
+ [DataRow(HashType.SHA256)]
+ public void SentinelsAreDeterministicForHashType(HashType hashType)
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(hashType).CreateContentHasher();
+ FingerprintFactory a = CreateFactory(hasher);
+ FingerprintFactory b = CreateFactory(hasher);
+
+ CollectionAssert.AreEqual(a.AbsentFileSentinel, b.AbsentFileSentinel, $"AbsentFileSentinel not deterministic for {hashType}.");
+ CollectionAssert.AreEqual(a.ZeroHash, b.ZeroHash, $"ZeroHash not deterministic for {hashType}.");
+ }
+
+ ///
+ /// The two sentinels must be distinct — they encode different semantic states (absent path vs.
+ /// existence-only observation) and must never collapse to the same fingerprint payload.
+ ///
+ [TestMethod]
+ [DataRow(HashType.Murmur)]
+ [DataRow(HashType.Vso0)]
+ [DataRow(HashType.SHA256)]
+ public void SentinelsAreDistinct(HashType hashType)
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(hashType).CreateContentHasher();
+ FingerprintFactory factory = CreateFactory(hasher);
+
+ CollectionAssert.AreNotEqual(factory.AbsentFileSentinel, factory.ZeroHash, "AbsentFileSentinel and ZeroHash must not collide.");
+ }
+
+ ///
+ /// Sentinels must match the active hasher's output byte length. This is essential for downstream
+ /// hash-combining calls that concatenate sentinel + real hashes into the final strong fingerprint.
+ ///
+ [TestMethod]
+ [DataRow(HashType.Murmur)]
+ [DataRow(HashType.Vso0)]
+ [DataRow(HashType.SHA256)]
+ public void SentinelsMatchHasherByteLength(HashType hashType)
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(hashType).CreateContentHasher();
+ int expectedLength = hasher.Info.ByteLength;
+ FingerprintFactory factory = CreateFactory(hasher);
+
+ Assert.AreEqual(expectedLength, factory.AbsentFileSentinel.Length, $"AbsentFileSentinel size mismatch for {hashType}.");
+ Assert.AreEqual(expectedLength, factory.ZeroHash.Length, $"ZeroHash size mismatch for {hashType}.");
+ }
+
+ ///
+ /// AbsentPathProbe and ExistingProbe of the same path must produce different strong fingerprints.
+ /// This is the clean→dirty→miss / dirty→clean→miss fix at the strong-FP layer — re-observation at
+ /// cache lookup will surface these type differences at lookup time.
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintDistinguishesAbsentFromExistent()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ FingerprintFactory absentFactory = CreateFactory(hasher);
+ FingerprintFactory existingFactory = CreateFactory(hasher);
+
+ PathSet absent = new(new List { new(@"{RepoRoot}\foo", ObservationType.AbsentPathProbe) });
+ PathSet existing = new(new List { new(@"{RepoRoot}\foo", ObservationType.ExistingProbe) });
+
+ Fingerprint? fpAbsent = await absentFactory.GetStrongFingerprintAsync(absent);
+ Fingerprint? fpExisting = await existingFactory.GetStrongFingerprintAsync(existing);
+
+ Assert.IsNotNull(fpAbsent);
+ Assert.IsNotNull(fpExisting);
+ CollectionAssert.AreNotEqual(fpAbsent.Hash, fpExisting.Hash,
+ "AbsentPathProbe and ExistingProbe of the same path must produce different strong fingerprints.");
+ }
+
+ ///
+ /// FileContentRead and ExistingProbe of the same path must produce different strong fingerprints.
+ /// Reading a file's content is strictly more information than probing for its existence; the strong
+ /// fingerprint must reflect this distinction.
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintDistinguishesProbeFromContentRead()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var inputHasher = new DictInputHasher(new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"X:\Repo\foo"] = new byte[] { 1, 2, 3 },
+ });
+ FingerprintFactory factory = CreateFactory(hasher, inputHasher);
+
+ // Need at least one non-FCR entry to force the type-aware encoding path. Both PathSets carry the
+ // same dummy AbsentPathProbe entry; the FCR-vs-Probe distinction is on the path under test.
+ PathSet probe = new(new List
+ {
+ new(@"{RepoRoot}\bar", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\foo", ObservationType.ExistingProbe),
+ });
+ PathSet read = new(new List
+ {
+ new(@"{RepoRoot}\bar", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\foo", ObservationType.FileContentRead),
+ });
+
+ Fingerprint? fpProbe = await CreateFactory(hasher, inputHasher).GetStrongFingerprintAsync(probe);
+ Fingerprint? fpRead = await CreateFactory(hasher, inputHasher).GetStrongFingerprintAsync(read);
+
+ Assert.IsNotNull(fpProbe);
+ Assert.IsNotNull(fpRead);
+ CollectionAssert.AreNotEqual(fpProbe.Hash, fpRead.Hash);
+ }
+
+ ///
+ /// When an ExistingProbe path's content changes but the path still exists, the strong fingerprint
+ /// must NOT change. Existence-only observations don't depend on content.
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintExistingProbeIgnoresContentChange()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+
+ // Two different input hashers with different content hashes for the same path. ExistingProbe entries
+ // ignore the hasher; the strong fingerprint should be identical regardless.
+ var hasherA = new DictInputHasher(new Dictionary(StringComparer.OrdinalIgnoreCase) { [@"X:\Repo\foo"] = new byte[] { 1 } });
+ var hasherB = new DictInputHasher(new Dictionary(StringComparer.OrdinalIgnoreCase) { [@"X:\Repo\foo"] = new byte[] { 2 } });
+
+ // Force the type-aware encoding by including a non-FCR sibling entry.
+ PathSet pathSet = new(new List
+ {
+ new(@"{RepoRoot}\bar", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\foo", ObservationType.ExistingProbe),
+ });
+
+ Fingerprint? fpA = await CreateFactory(hasher, hasherA).GetStrongFingerprintAsync(pathSet);
+ Fingerprint? fpB = await CreateFactory(hasher, hasherB).GetStrongFingerprintAsync(pathSet);
+
+ Assert.IsNotNull(fpA);
+ Assert.IsNotNull(fpB);
+ CollectionAssert.AreEqual(fpA.Hash, fpB.Hash,
+ "ExistingProbe must NOT incorporate file content into the strong fingerprint.");
+ }
+
+ ///
+ /// Adding a member to an enumerated directory must change the strong fingerprint. Under the
+ /// schema-driven model the strong-FP comes from the entry's Members field directly,
+ /// so two PathSets that differ only in their Members lists produce different fingerprints. (Detection
+ /// of filesystem changes between populate and lookup happens via MatchesCurrentState — see
+ /// MatchesCurrentStateDirectoryEnumerationDetectsExternalMemberAdded.)
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintDirectoryMemberHashDetectsAddition()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+
+ PathSet before = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\dir", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs" },
+ writtenMembers: Array.Empty()),
+ });
+ PathSet after = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\dir", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs", "b.cs" },
+ writtenMembers: Array.Empty()),
+ });
+
+ Fingerprint? fpBefore = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(before);
+ Fingerprint? fpAfter = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(after);
+
+ Assert.IsNotNull(fpBefore);
+ Assert.IsNotNull(fpAfter);
+ CollectionAssert.AreNotEqual(fpBefore.Hash, fpAfter.Hash,
+ "PathSets that differ only in DirectoryEnumeration.Members must produce different strong fingerprints.");
+ }
+
+ ///
+ /// Removing a member from an enumerated directory must change the strong fingerprint. Schema-driven
+ /// counterpart of the addition test.
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintDirectoryMemberHashDetectsRemoval()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+
+ PathSet before = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\dir", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs", "b.cs" },
+ writtenMembers: Array.Empty()),
+ });
+ PathSet after = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\dir", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs" },
+ writtenMembers: Array.Empty()),
+ });
+
+ Fingerprint? fpBefore = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(before);
+ Fingerprint? fpAfter = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(after);
+
+ Assert.IsNotNull(fpBefore);
+ Assert.IsNotNull(fpAfter);
+ CollectionAssert.AreNotEqual(fpBefore.Hash, fpAfter.Hash);
+ }
+
+ ///
+ /// Changing the content of a directory member (without changing the member list) must NOT change the
+ /// strong fingerprint when the entry is DirectoryEnumeration. The enumeration only depends on the
+ /// member-name list (carried in the entry's Members); file content is irrelevant.
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintDirectoryMemberHashIgnoresMemberContentChange()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+
+ // Two PathSets with the same Members list. Member file content is not represented in the schema
+ // for DirEnum entries, so two PathSets with the same Members must produce identical strong FPs.
+ PathSet before = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\dir", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs" },
+ writtenMembers: Array.Empty()),
+ });
+ PathSet after = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\dir", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs" },
+ writtenMembers: Array.Empty()),
+ });
+
+ Fingerprint? fpBefore = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(before);
+ Fingerprint? fpAfter = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(after);
+
+ Assert.IsNotNull(fpBefore);
+ Assert.IsNotNull(fpAfter);
+ CollectionAssert.AreEqual(fpBefore.Hash, fpAfter.Hash,
+ "DirectoryEnumeration must NOT depend on member file contents — only on the member-name list.");
+ }
+
+ ///
+ /// ComputeDirectoryMemberHash: missing directory → AbsentFileSentinel (verified indirectly by
+ /// fingerprinting a PathSet with a DirectoryEnumeration of a missing path).
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintDirectoryEnumerationMissingDirIsAbsent()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(@"X:\NonExistentRepo", @"X:\Nuget");
+
+ PathSet pathSetMissing = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\definitely-not-here", ObservationType.DirectoryEnumeration),
+ });
+ PathSet pathSetAbsent = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(@"{RepoRoot}\definitely-not-here", ObservationType.AbsentPathProbe),
+ });
+
+ Fingerprint? fpMissingDir = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(pathSetMissing);
+ Fingerprint? fpAbsent = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(pathSetAbsent);
+
+ Assert.IsNotNull(fpMissingDir);
+ Assert.IsNotNull(fpAbsent);
+ // The two should still differ because the Type tag differs, but the underlying member-hash payload
+ // for the missing-directory case is AbsentFileSentinel (same payload bytes as the AbsentPathProbe).
+ // Different Type entries → different overall fingerprint.
+ CollectionAssert.AreNotEqual(fpMissingDir.Hash, fpAbsent.Hash);
+ }
+
+ ///
+ /// Determinism: hashing the same PathSet twice produces the same strong fingerprint, even with member
+ /// hashing (no nondeterministic FS enumeration ordering leaking through).
+ ///
+ [TestMethod]
+ public async Task StrongFingerprintIsDeterministic()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-Determinism");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ // Populate enough members to potentially exercise FS-ordering nondeterminism.
+ foreach (string name in new[] { "z.cs", "a.cs", "M.cs", "b.cs", "Y.txt" })
+ {
+ WriteTextSync(Path.Combine(tempRepo.Path, name), "");
+ }
+
+ string normalizedDir = pathNormalizer.Normalize(tempRepo.Path);
+ PathSet pathSet = new(new List
+ {
+ new(@"{RepoRoot}\sentinel", ObservationType.AbsentPathProbe),
+ new(normalizedDir, ObservationType.DirectoryEnumeration),
+ });
+
+ Fingerprint? fp1 = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(pathSet);
+ Fingerprint? fp2 = await CreateFactory(hasher, pathNormalizer: pathNormalizer).GetStrongFingerprintAsync(pathSet);
+
+ Assert.IsNotNull(fp1);
+ Assert.IsNotNull(fp2);
+ CollectionAssert.AreEqual(fp1.Hash, fp2.Hash,
+ "Strong fingerprint must be deterministic across factory instances.");
+ }
+
+ // =========================================================================================
+ // MatchesCurrentState: cheap probe/enumeration verification at cache lookup time.
+ //
+ // Returns true if every non-FCR observation in the cached PathSet still matches current filesystem
+ // state. The caller (CacheClient) combines this with a standard strong-FP comparison: if
+ // MatchesCurrentState is false, skip the selector entirely; if true, compute the strong FP from the
+ // cached PathSet (which hashes current FCR content) and compare to the cached selector's FP.
+ // =========================================================================================
+
+ ///
+ /// Cached AbsentPathProbe with current path now present → MatchesCurrentState returns false (cache MISS
+ /// without strong-FP computation). This is the clean→dirty→miss fix.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateAbsentBecomesPresentReturnsFalse()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchAbsentBecomesPresent");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\generated.txt", ObservationType.AbsentPathProbe),
+ });
+
+ // The path now exists on disk; the cached AbsentPathProbe no longer reflects reality.
+ WriteTextSync(Path.Combine(tempRepo.Path, "generated.txt"), "now exists");
+
+ Assert.IsFalse(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// Cached ExistingProbe with current path now absent → MatchesCurrentState returns false.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateExistingBecomesAbsentReturnsFalse()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchExistingBecomesAbsent");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ string filePath = Path.Combine(tempRepo.Path, "tooling.dll");
+ WriteTextSync(filePath, "");
+
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\tooling.dll", ObservationType.ExistingProbe),
+ });
+
+ File.Delete(filePath);
+
+ Assert.IsFalse(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// State unchanged → MatchesCurrentState returns true, AND the strong fingerprint of the cached PathSet
+ /// equals the populate-time fingerprint. This is the cache-hit case.
+ ///
+ [TestMethod]
+ public async Task MatchesCurrentStateUnchangedHitsAndFingerprintMatches()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchUnchanged");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ WriteTextSync(Path.Combine(tempRepo.Path, "stable.dll"), "");
+
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\stable.dll", ObservationType.ExistingProbe),
+ new(@"{RepoRoot}\never-existed.dll", ObservationType.AbsentPathProbe),
+ });
+
+ FingerprintFactory factoryAtPopulate = CreateFactory(hasher, pathNormalizer: pathNormalizer);
+ Fingerprint? populateFp = await factoryAtPopulate.GetStrongFingerprintAsync(cachedPathSet);
+
+ FingerprintFactory factoryAtLookup = CreateFactory(hasher, pathNormalizer: pathNormalizer);
+ Assert.IsTrue(factoryAtLookup.MatchesCurrentState(cachedPathSet),
+ "MatchesCurrentState must return true when probes still match reality.");
+
+ Fingerprint? lookupFp = await factoryAtLookup.GetStrongFingerprintAsync(cachedPathSet);
+ Assert.IsNotNull(populateFp);
+ Assert.IsNotNull(lookupFp);
+ CollectionAssert.AreEqual(populateFp.Hash, lookupFp.Hash,
+ "Cached strong FP must match recomputed FP when state is unchanged.");
+ }
+
+ ///
+ /// Three-build clean → dirty → clean cycle. MatchesCurrentState must return true → false → true across
+ /// the cycle, and the Build 3 fingerprint matches the Build 1 fingerprint (cache hit recovered).
+ ///
+ [TestMethod]
+ public async Task MatchesCurrentStateCleanDirtyCleanCycleRecoversHit()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchCleanDirtyClean");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ string fsPath = Path.Combine(tempRepo.Path, "ephemeral.dat");
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\ephemeral.dat", ObservationType.AbsentPathProbe),
+ });
+
+ // Build 1: clean, file absent → matches → cache the strong FP.
+ FingerprintFactory build1 = CreateFactory(hasher, pathNormalizer: pathNormalizer);
+ Assert.IsTrue(build1.MatchesCurrentState(cachedPathSet));
+ Fingerprint? build1Fp = await build1.GetStrongFingerprintAsync(cachedPathSet);
+
+ // Build 2: dirty, file appears → MatchesCurrentState false; skip selector.
+ WriteTextSync(fsPath, "");
+ FingerprintFactory build2 = CreateFactory(hasher, pathNormalizer: pathNormalizer);
+ Assert.IsFalse(build2.MatchesCurrentState(cachedPathSet));
+
+ // Build 3: clean again, file removed → MatchesCurrentState true again; FP recomputes to build1's.
+ File.Delete(fsPath);
+ FingerprintFactory build3 = CreateFactory(hasher, pathNormalizer: pathNormalizer);
+ Assert.IsTrue(build3.MatchesCurrentState(cachedPathSet));
+ Fingerprint? build3Fp = await build3.GetStrongFingerprintAsync(cachedPathSet);
+
+ Assert.IsNotNull(build1Fp);
+ Assert.IsNotNull(build3Fp);
+ CollectionAssert.AreEqual(build1Fp.Hash, build3Fp.Hash,
+ "Build 3 (clean again) must produce the Build 1 fingerprint so the cache hit is recovered.");
+ }
+
+ ///
+ /// All-FCR PathSet → MatchesCurrentState returns true vacuously (no probes/enums to check); FCR
+ /// validation happens implicitly via the strong-FP computation's content hashing.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateAllFileContentReadReturnsTrue()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\a.cs", ObservationType.FileContentRead),
+ new(@"{RepoRoot}\b.cs", ObservationType.FileContentRead),
+ });
+
+ Assert.IsTrue(CreateFactory(hasher).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// Null or empty cached PathSet → MatchesCurrentState returns true vacuously.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateNullOrEmptyReturnsTrue()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ FingerprintFactory factory = CreateFactory(hasher);
+
+ Assert.IsTrue(factory.MatchesCurrentState(null));
+ Assert.IsTrue(factory.MatchesCurrentState(new PathSet(new List())));
+ }
+
+ [TestMethod]
+ public void MatchesCurrentStateUnknownObservationTypeReturnsFalse()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\future-observation", (ObservationType)byte.MaxValue),
+ });
+
+ Assert.IsFalse(
+ CreateFactory(hasher).MatchesCurrentState(cachedPathSet),
+ "Observation types unknown to this client must force a miss rather than silently losing a dependency.");
+ }
+
+ // =========================================================================================
+ // Self-output enumeration cycle — the schema-driven fix.
+ //
+ // These tests pin the load-bearing property: when a project enumerates a directory it also
+ // writes into, populate-time captures the partition into Members (external dependency) and
+ // WrittenMembers (project's own outputs). Lookup-time MatchesCurrentState subtracts cached.WrittenMembers
+ // from the current contents and compares against cached.Members — which makes cache hits
+ // robust to the previous build's outputs being either present (incremental) or absent (clean).
+ // =========================================================================================
+
+ ///
+ /// The headline cycle case: cached PathSet has DirectoryEnumeration with WrittenMembers=[Foo.dll, Foo.pdb]
+ /// (the project's self-outputs) and Members=[] (no external dependency members). MatchesCurrentState
+ /// against an empty directory (clean state) subtracts cached.WrittenMembers from {} → effective is []
+ /// → matches cached.Members → returns true.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateDirectoryEnumerationCleanStateMatchesCachedSelfOutputs()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchDirEnumCleanCycle");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ string normalizedDir = pathNormalizer.Normalize(tempRepo.Path);
+ PathSet cachedPathSet = new(new List
+ {
+ new(normalizedDir, ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: Array.Empty(),
+ writtenMembers: new[] { "Foo.dll", "Foo.pdb" }),
+ });
+
+ // Lookup with empty directory (clean rebuild): subtract WrittenMembers from {} → [] → matches.
+ Assert.IsTrue(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet),
+ "MatchesCurrentState must return true for an empty directory when cached.WrittenMembers cancels out the previous build's contribution.");
+
+ // Lookup with leftover outputs from the previous build (incremental scenario): subtract
+ // {Foo.dll, Foo.pdb} → [] → matches.
+ WriteTextSync(Path.Combine(tempRepo.Path, "Foo.dll"), "");
+ WriteTextSync(Path.Combine(tempRepo.Path, "Foo.pdb"), "");
+
+ Assert.IsTrue(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet),
+ "MatchesCurrentState must return true for an incremental rebuild where leftover self-outputs are still on disk.");
+ }
+
+ ///
+ /// External-member regression: if a NEW non-self-output file appears in the enumerated directory
+ /// between populate and lookup, MatchesCurrentState must return false.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateDirectoryEnumerationDetectsExternalMemberAdded()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchDirEnumExternalAdded");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ string normalizedDir = pathNormalizer.Normalize(tempRepo.Path);
+ PathSet cachedPathSet = new(new List
+ {
+ new(normalizedDir, ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: Array.Empty(),
+ writtenMembers: new[] { "Foo.dll" }),
+ });
+
+ // A sibling project's output now sits in the directory.
+ WriteTextSync(Path.Combine(tempRepo.Path, "Sibling.dll"), "");
+
+ Assert.IsFalse(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// Stable external membership: cached Members=[Manifest.json], WrittenMembers=[Foo.dll]. Both clean
+ /// (only Manifest.json) and incremental (Manifest.json + Foo.dll) states match.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateDirectoryEnumerationStableExternalMembers()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchDirEnumStableExternal");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ string normalizedDir = pathNormalizer.Normalize(tempRepo.Path);
+ PathSet cachedPathSet = new(new List
+ {
+ new(normalizedDir, ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "Manifest.json" },
+ writtenMembers: new[] { "Foo.dll" }),
+ });
+
+ WriteTextSync(Path.Combine(tempRepo.Path, "Manifest.json"), "{}");
+ Assert.IsTrue(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet),
+ "Clean lookup with stable external member must match.");
+
+ WriteTextSync(Path.Combine(tempRepo.Path, "Foo.dll"), "");
+ Assert.IsTrue(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet),
+ "Incremental lookup with stable external member must match.");
+ }
+
+ ///
+ /// External-member removed: cached Members=[Manifest.json], WrittenMembers=[Foo.dll]. If the external
+ /// file disappears between populate and lookup, MatchesCurrentState must return false.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateDirectoryEnumerationDetectsExternalMemberRemoved()
+ {
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-MatchDirEnumExternalRemoved");
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ string normalizedDir = pathNormalizer.Normalize(tempRepo.Path);
+ PathSet cachedPathSet = new(new List
+ {
+ new(normalizedDir, ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "Manifest.json" },
+ writtenMembers: new[] { "Foo.dll" }),
+ });
+
+ // Lookup with NEITHER file present — the external dependency Manifest.json is gone.
+ Assert.IsFalse(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// Cached DirectoryEnumeration of a path that is no longer a directory at lookup time → returns false.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateDirectoryEnumerationOfAbsentDirReturnsFalse()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ var pathNormalizer = new PathNormalizer(@"X:\NonExistentRepo", @"X:\Nuget");
+
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\definitely-not-here", ObservationType.DirectoryEnumeration,
+ enumerationPattern: null,
+ members: Array.Empty(),
+ writtenMembers: null),
+ });
+
+ Assert.IsFalse(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// A directory that was absent when the entry was produced, and is still absent, must re-validate.
+ /// PartitionDirectoryMembers records a null member list for a directory it could not enumerate,
+ /// and ComputeDirectoryMemberHash encodes that as distinct from an empty one, so the null case
+ /// has to be validated as absence. Validating it by re-enumeration reports a miss precisely because
+ /// nothing changed, which would make any project enumerating a missing directory — an ordinary
+ /// consequence of a wildcard over a directory that does not exist — permanently uncacheable.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateDirectoryEnumerationAbsentAtPopulateAndStillAbsentReturnsTrue()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-DirEnumStillAbsent");
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ // members: null is the "could not enumerate" state, as opposed to an empty list, which means the
+ // directory existed and had no members.
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\never-created", ObservationType.DirectoryEnumeration,
+ enumerationPattern: null,
+ members: null,
+ writtenMembers: null),
+ });
+
+ Assert.IsTrue(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// The other direction: a directory that was absent when the entry was produced but exists now has
+ /// changed, so the entry must not re-validate.
+ ///
+ [TestMethod]
+ public void MatchesCurrentStateDirectoryEnumerationAbsentAtPopulateNowExistsReturnsFalse()
+ {
+ using IContentHasher hasher = HashInfoLookup.Find(HashType.Murmur).CreateContentHasher();
+ using TempDirectory tempRepo = TempDirectory.Create("MSBuildCacheTest-DirEnumNowExists");
+ var pathNormalizer = new PathNormalizer(tempRepo.Path, @"X:\Nuget");
+
+ PathSet cachedPathSet = new(new List
+ {
+ new(@"{RepoRoot}\appears-later", ObservationType.DirectoryEnumeration,
+ enumerationPattern: null,
+ members: null,
+ writtenMembers: null),
+ });
+
+ Directory.CreateDirectory(Path.Combine(tempRepo.Path, "appears-later"));
+
+ Assert.IsFalse(CreateFactory(hasher, pathNormalizer: pathNormalizer).MatchesCurrentState(cachedPathSet));
+ }
+
+ ///
+ /// Patterns reported by the sandbox may contain the native DOS wildcard tokens. Re-enumeration must
+ /// pass them through to the native filesystem layer rather than rejecting them as invalid managed
+ /// glob syntax.
+ ///
+ [TestMethod]
+ [DataRow("<", DisplayName = "DOS_STAR")]
+ [DataRow(">", DisplayName = "DOS_QM")]
+ [DataRow("\"", DisplayName = "DOS_DOT")]
+ [DataRow("*.\"", DisplayName = "extension-less files")]
+ [DataRow("{", DisplayName = "open brace")]
+ [DataRow("]", DisplayName = "close bracket")]
+ public void EnumerateAndSubtractAcceptsNativePattern(string pattern)
+ {
+ using TempDirectory tempDir = TempDirectory.Create("MSBuildCacheTest-BadPattern");
+ WriteTextSync(Path.Combine(tempDir.Path, "a.cs"), "");
+ WriteTextSync(Path.Combine(tempDir.Path, "b.txt"), "");
+
+ IReadOnlyList? result = FingerprintFactory.EnumerateAndSubtract(tempDir.Path, pattern, writtenMembersToSubtract: null);
+
+ Assert.IsNotNull(result, "A pattern accepted and reported by the sandbox must remain enumerable at lookup.");
+ }
+
+ [TestMethod]
+ public void EnumerateAndSubtractUsesWin32StarDotStarSemantics()
+ {
+ using TempDirectory tempDir = TempDirectory.Create("MSBuildCacheTest-StarDotStar");
+ WriteTextSync(Path.Combine(tempDir.Path, "README"), "");
+ WriteTextSync(Path.Combine(tempDir.Path, "a.cs"), "");
+
+ IReadOnlyList? result =
+ FingerprintFactory.EnumerateAndSubtract(tempDir.Path, "*.*", writtenMembersToSubtract: null);
+
+ Assert.IsNotNull(result);
+ CollectionAssert.AreEquivalent(new[] { "README", "a.cs" }, result.ToArray());
+ }
+
+ [TestMethod]
+ public void EnumerateAndSubtractExcludesIgnoredMembers()
+ {
+ using TempDirectory tempDir = TempDirectory.Create("MSBuildCacheTest-IgnoredEnumerationMember");
+ string ignoredPath = Path.Combine(tempDir.Path, "noisy.marker");
+ WriteTextSync(ignoredPath, "");
+ WriteTextSync(Path.Combine(tempDir.Path, "stable.input"), "");
+
+ IReadOnlyList? result = FingerprintFactory.EnumerateAndSubtract(
+ tempDir.Path,
+ enumerationPattern: null,
+ writtenMembersToSubtract: null,
+ ignoredInputPatterns: new[] { Glob.Parse(ignoredPath) });
+
+ Assert.IsNotNull(result);
+ CollectionAssert.AreEqual(new[] { "stable.input" }, result.ToArray());
+ }
+
+ ///
+ /// must return null (not an empty list)
+ /// on IO failure, so the caller can distinguish "couldn't observe" from "observed an empty directory".
+ /// Passing a file path (rather than a directory) reliably triggers .
+ ///
+ [TestMethod]
+ public void EnumerateAndSubtractReturnsNullOnIOFailure()
+ {
+ using TempDirectory tempDir = TempDirectory.Create("MSBuildCacheTest-EnumerateAndSubtractIOFailure");
+ string filePath = Path.Combine(tempDir.Path, "this-is-a-file-not-a-directory.txt");
+ File.WriteAllText(filePath, string.Empty);
+
+ IReadOnlyList? result = FingerprintFactory.EnumerateAndSubtract(filePath, enumerationPattern: null, writtenMembersToSubtract: null);
+
+ Assert.IsNull(result, "EnumerateAndSubtract must return null (not an empty list) when the underlying FS call throws IOException.");
+ }
+
+ ///
+ /// Windows filesystem matching is case-insensitive (NtQueryDirectoryFile / FindFirstFileEx), so
+ /// pattern matching in must be too. A
+ /// pattern of *.cs recorded at populate time must match a file named Foo.CS on
+ /// disk at lookup time — otherwise re-enumeration silently filters away files that would have
+ /// matched the original syscall, producing false cache misses.
+ ///
+ [TestMethod]
+ public void EnumerateAndSubtractIsCaseInsensitive()
+ {
+ using TempDirectory tempDir = TempDirectory.Create("MSBuildCacheTest-EnumerateAndSubtractCase");
+ File.WriteAllText(Path.Combine(tempDir.Path, "Foo.CS"), string.Empty);
+ File.WriteAllText(Path.Combine(tempDir.Path, "bar.cs"), string.Empty);
+ File.WriteAllText(Path.Combine(tempDir.Path, "skip.txt"), string.Empty);
+
+ IReadOnlyList? result = FingerprintFactory.EnumerateAndSubtract(tempDir.Path, enumerationPattern: "*.cs", writtenMembersToSubtract: null);
+
+ Assert.IsNotNull(result);
+ CollectionAssert.AreEquivalent(new[] { "Foo.CS", "bar.cs" }, result.ToArray());
+ }
+
+ // =========================================================================================
+ // Same-path same-type pattern-fold (multi-pattern handling).
+ //
+ // Tests pin the precedence rule plus the multi-pattern handling for DirectoryEnumeration entries —
+ // different patterns are kept as distinct entries, identical patterns dedupe.
+ // =========================================================================================
+
+ ///
+ /// Flag off: non-FCR observations are dropped; only FCR entries contribute.
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesFlagOffIgnoresObservations()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("a.cs", ObservationType.FileContentRead),
+ new ObservedPathEntry("b.cs", ObservationType.ExistingProbe),
+ },
+ enableProbeAndEnumeration: false);
+
+ Assert.AreEqual(1, entries.Count);
+ Assert.AreEqual("a.cs", entries[0].Path);
+ Assert.AreEqual(ObservationType.FileContentRead, entries[0].Type);
+ }
+
+ ///
+ /// FCR plus a probe of the same path: precedence keeps the FCR (FileContentRead > ExistingProbe).
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesPrecedenceKeepsContentReadOverProbe()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("shared.dll", ObservationType.FileContentRead),
+ new ObservedPathEntry("shared.dll", ObservationType.ExistingProbe),
+ },
+ enableProbeAndEnumeration: true);
+
+ Assert.AreEqual(1, entries.Count);
+ Assert.AreEqual(ObservationType.FileContentRead, entries[0].Type);
+ }
+
+ ///
+ /// Probe-first then read: the FCR observation strictly outranks the previously-folded probe, so the
+ /// final entry is FCR.
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesPrecedenceReplacesProbeWithRead()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("foo", ObservationType.AbsentPathProbe),
+ new ObservedPathEntry("foo", ObservationType.ExistingProbe),
+ new ObservedPathEntry("foo", ObservationType.FileContentRead),
+ },
+ enableProbeAndEnumeration: true);
+
+ Assert.AreEqual(1, entries.Count);
+ Assert.AreEqual(ObservationType.FileContentRead, entries[0].Type);
+ }
+
+ ///
+ /// Same-path DirectoryEnumeration entries with different patterns must each appear as separate entries
+ /// in the PathSet — this is the multi-pattern handling. Today this bug is latent because
+ /// EnumerationPattern is always null from MSBuild's FileAccessData, but the schema is ready.
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesDirectoryEnumerationKeepsDistinctPatterns()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("dir", ObservationType.DirectoryEnumeration, "*.cs"),
+ new ObservedPathEntry("dir", ObservationType.DirectoryEnumeration, "*.dll"),
+ },
+ enableProbeAndEnumeration: true);
+
+ Assert.AreEqual(2, entries.Count, "Same-path DirectoryEnumeration with different patterns must produce TWO entries.");
+ CollectionAssert.AreEqual(
+ new[] { "*.cs", "*.dll" },
+ entries.Select(e => e.EnumerationPattern).ToArray());
+ Assert.IsTrue(entries.All(e => e.Type == ObservationType.DirectoryEnumeration));
+ Assert.IsTrue(entries.All(e => e.Path == "dir"));
+ }
+
+ ///
+ /// Same-path DirectoryEnumeration entries with the SAME pattern fold to one entry — duplicate
+ /// observations dedup correctly.
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesDirectoryEnumerationFoldsDuplicatePatterns()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("dir", ObservationType.DirectoryEnumeration, "*.cs"),
+ new ObservedPathEntry("dir", ObservationType.DirectoryEnumeration, "*.cs"),
+ new ObservedPathEntry("dir", ObservationType.DirectoryEnumeration, "*.cs"),
+ },
+ enableProbeAndEnumeration: true);
+
+ Assert.AreEqual(1, entries.Count);
+ Assert.AreEqual("*.cs", entries[0].EnumerationPattern);
+ }
+
+ ///
+ /// FCR followed by a DirectoryEnumeration of the same path: FCR has higher precedence, so the
+ /// final entry is FCR — the enumeration is dropped.
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesContentReadOutranksEnumeration()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("ambiguous", ObservationType.FileContentRead),
+ new ObservedPathEntry("ambiguous", ObservationType.DirectoryEnumeration, "*.cs"),
+ },
+ enableProbeAndEnumeration: true);
+
+ Assert.AreEqual(1, entries.Count);
+ Assert.AreEqual(ObservationType.FileContentRead, entries[0].Type);
+ Assert.IsNull(entries[0].EnumerationPattern);
+ }
+
+ ///
+ /// DirectoryEnumeration observations followed by an FCR observation of the same path: FCR replaces all
+ /// existing DirEnum entries (including multi-pattern entries) with a single FCR entry.
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesContentReadReplacesMultiPatternEnumeration()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("ambiguous", ObservationType.DirectoryEnumeration, "*.cs"),
+ new ObservedPathEntry("ambiguous", ObservationType.DirectoryEnumeration, "*.dll"),
+ new ObservedPathEntry("ambiguous", ObservationType.FileContentRead),
+ },
+ enableProbeAndEnumeration: true);
+
+ Assert.AreEqual(1, entries.Count);
+ Assert.AreEqual(ObservationType.FileContentRead, entries[0].Type);
+ }
+
+ ///
+ /// Sort order pin: (Path OrdinalIgnoreCase, Type ascending, Pattern Ordinal).
+ ///
+ [TestMethod]
+ public void FoldPathSetEntriesProducesCanonicalSort()
+ {
+ List entries = FingerprintFactory.FoldPathSetEntries(
+ observations: new[]
+ {
+ new ObservedPathEntry("zeta", ObservationType.AbsentPathProbe),
+ new ObservedPathEntry("alpha", ObservationType.ExistingProbe),
+ new ObservedPathEntry("alpha", ObservationType.AbsentPathProbe), // dropped (lower precedence)
+ new ObservedPathEntry("mike", ObservationType.DirectoryEnumeration, "*.cs"),
+ new ObservedPathEntry("mike", ObservationType.DirectoryEnumeration, "*.dll"),
+ },
+ enableProbeAndEnumeration: true);
+
+ Assert.AreEqual(4, entries.Count);
+ Assert.AreEqual("alpha", entries[0].Path);
+ Assert.AreEqual(ObservationType.ExistingProbe, entries[0].Type);
+ Assert.AreEqual("mike", entries[1].Path);
+ Assert.AreEqual("*.cs", entries[1].EnumerationPattern);
+ Assert.AreEqual("mike", entries[2].Path);
+ Assert.AreEqual("*.dll", entries[2].EnumerationPattern);
+ Assert.AreEqual("zeta", entries[3].Path);
+ }
+
+ // =========================================================================================
+ // FilterObservations: predicted-input + hasher-can-hash + scope filter for sandbox observations.
+ // =========================================================================================
+
+ private static readonly PathNormalizer TestNormalizer = new(@"X:\Repo", @"X:\Nuget");
+ private static readonly HashSet EmptyPredictedInputs = new(StringComparer.OrdinalIgnoreCase);
+ private static readonly IReadOnlyCollection EmptyIgnoredPatterns = Array.Empty();
+
+ ///
+ /// FCR observation whose path the hasher can hash → included as a normalized FCR entry.
+ ///
+ [TestMethod]
+ public void FilterObservationsFcrIncludedWhenHasherCanHash()
+ {
+ var inputHasher = new DictInputHasher(new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"X:\Repo\src\foo.cs"] = new byte[] { 1 },
+ });
+
+ (List included, List excluded) = FingerprintFactory.FilterObservations(
+ new[] { new ObservedAccess(@"X:\Repo\src\foo.cs", ObservationType.FileContentRead) },
+ EmptyPredictedInputs,
+ inputHasher,
+ TestNormalizer,
+ EmptyIgnoredPatterns);
+
+ Assert.AreEqual(1, included.Count);
+ Assert.AreEqual(@"{RepoRoot}src\foo.cs", included[0].Path);
+ Assert.AreEqual(ObservationType.FileContentRead, included[0].Type);
+ Assert.AreEqual(0, excluded.Count);
+ }
+
+ ///
+ /// FCR observation whose path the hasher cannot hash → routed to the excluded list (for debug log),
+ /// not the PathSet.
+ ///
+ [TestMethod]
+ public void FilterObservationsFcrExcludedWhenHasherCannotHash()
+ {
+ (List included, List excluded) = FingerprintFactory.FilterObservations(
+ new[] { new ObservedAccess(@"X:\Repo\src\foo.cs", ObservationType.FileContentRead) },
+ EmptyPredictedInputs,
+ new NoopInputHasher(),
+ TestNormalizer,
+ EmptyIgnoredPatterns);
+
+ Assert.AreEqual(0, included.Count);
+ Assert.AreEqual(1, excluded.Count);
+ Assert.AreEqual(@"{RepoRoot}src\foo.cs", excluded[0]);
+ }
+
+ ///
+ /// In-scope probe → included as a normalized entry.
+ ///
+ [TestMethod]
+ public void FilterObservationsKeepsInScopeProbe()
+ {
+ (List included, List excluded) = FingerprintFactory.FilterObservations(
+ new[] { new ObservedAccess(@"X:\Repo\src\foo.cs", ObservationType.ExistingProbe) },
+ EmptyPredictedInputs,
+ new NoopInputHasher(),
+ TestNormalizer,
+ EmptyIgnoredPatterns);
+
+ Assert.AreEqual(1, included.Count);
+ Assert.AreEqual(@"{RepoRoot}src\foo.cs", included[0].Path);
+ Assert.AreEqual(ObservationType.ExistingProbe, included[0].Type);
+ Assert.AreEqual(0, excluded.Count);
+ }
+
+ ///
+ /// Out-of-scope probe → dropped silently (not in PathSet, not in debug log).
+ ///
+ [TestMethod]
+ public void FilterObservationsDropsOutOfScopeProbe()
+ {
+ (List included, List excluded) = FingerprintFactory.FilterObservations(
+ new[] { new ObservedAccess(@"C:\ProgramData\Microsoft\NetFramework\BreadcrumbStore\Foo", ObservationType.AbsentPathProbe) },
+ EmptyPredictedInputs,
+ new NoopInputHasher(),
+ TestNormalizer,
+ EmptyIgnoredPatterns);
+
+ Assert.AreEqual(0, included.Count);
+ Assert.AreEqual(0, excluded.Count);
+ }
+
+ ///
+ /// Predicted inputs are dropped regardless of observation type — they're already covered by the weak FP.
+ ///
+ [TestMethod]
+ public void FilterObservationsDropsPredictedInputs()
+ {
+ HashSet predicted = new(StringComparer.OrdinalIgnoreCase)
+ {
+ @"X:\Repo\src\predicted.cs",
+ };
+ var inputHasher = new DictInputHasher(new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"X:\Repo\src\predicted.cs"] = new byte[] { 1 },
+ });
+
+ (List included, List excluded) = FingerprintFactory.FilterObservations(
+ new[]
+ {
+ new ObservedAccess(@"X:\Repo\src\predicted.cs", ObservationType.FileContentRead),
+ new ObservedAccess(@"X:\Repo\src\predicted.cs", ObservationType.ExistingProbe),
+ },
+ predicted,
+ inputHasher,
+ TestNormalizer,
+ EmptyIgnoredPatterns);
+
+ Assert.AreEqual(0, included.Count);
+ Assert.AreEqual(0, excluded.Count);
+ }
+
+ ///
+ /// DirectoryEnumeration entries preserve EnumerationPattern, Members, and WrittenMembers through the filter.
+ ///
+ [TestMethod]
+ public void FilterObservationsPreservesDirectoryEnumerationFields()
+ {
+ (List included, _) = FingerprintFactory.FilterObservations(
+ new[]
+ {
+ new ObservedAccess(
+ @"X:\Repo\dir",
+ ObservationType.DirectoryEnumeration,
+ EnumerationPattern: "*.cs",
+ Members: new[] { "a.cs", "b.cs" },
+ WrittenMembers: new[] { "Foo.dll" }),
+ },
+ EmptyPredictedInputs,
+ new NoopInputHasher(),
+ TestNormalizer,
+ EmptyIgnoredPatterns);
+
+ Assert.AreEqual(1, included.Count);
+ Assert.AreEqual(ObservationType.DirectoryEnumeration, included[0].Type);
+ Assert.AreEqual("*.cs", included[0].EnumerationPattern);
+ CollectionAssert.AreEqual(new[] { "a.cs", "b.cs" }, included[0].Members?.ToArray());
+ CollectionAssert.AreEqual(new[] { "Foo.dll" }, included[0].WrittenMembers?.ToArray());
+ }
+
+ ///
+ /// Empty input → empty output.
+ ///
+ [TestMethod]
+ public void FilterObservationsEmptyInput()
+ {
+ (List included, List excluded) = FingerprintFactory.FilterObservations(
+ Array.Empty(),
+ EmptyPredictedInputs,
+ new NoopInputHasher(),
+ TestNormalizer,
+ EmptyIgnoredPatterns);
+
+ Assert.AreEqual(0, included.Count);
+ Assert.AreEqual(0, excluded.Count);
+ }
+
+ ///
+ /// Paths matching IgnoredInputPatterns are dropped regardless of observation type. The pattern is
+ /// matched against the absolute path (pre-normalization).
+ ///
+ [TestMethod]
+ public void FilterObservationsDropsIgnoredInputPatterns()
+ {
+ var inputHasher = new DictInputHasher(new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [@"X:\Repo\obj\noisy.cache"] = new byte[] { 1 },
+ [@"X:\Repo\src\foo.cs"] = new byte[] { 2 },
+ });
+ IReadOnlyCollection ignored = new[] { Glob.Parse(@"X:\Repo\obj\**\*.cache") };
+
+ (List included, List excluded) = FingerprintFactory.FilterObservations(
+ new[]
+ {
+ new ObservedAccess(@"X:\Repo\obj\noisy.cache", ObservationType.FileContentRead),
+ new ObservedAccess(@"X:\Repo\obj\noisy.cache", ObservationType.ExistingProbe),
+ new ObservedAccess(@"X:\Repo\src\foo.cs", ObservationType.FileContentRead),
+ },
+ EmptyPredictedInputs,
+ inputHasher,
+ TestNormalizer,
+ ignored);
+
+ Assert.AreEqual(1, included.Count);
+ Assert.AreEqual(@"{RepoRoot}src\foo.cs", included[0].Path);
+ Assert.AreEqual(0, excluded.Count, "Ignored paths must not appear in the excluded debug list either.");
+ }
+
+ private static FingerprintFactory CreateFactory(IContentHasher hasher, IInputHasher? inputHasher = null, PathNormalizer? pathNormalizer = null)
+ {
+ inputHasher ??= new NoopInputHasher();
+ pathNormalizer ??= new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+ var settings = new TestPluginSettings { RepoRoot = @"X:\Repo" };
+ return new FingerprintFactory(hasher, inputHasher, settings, pathNormalizer);
+ }
+
+ private static List GetPluginSettingsFingerprintEntries(FingerprintFactory factory)
+ {
+ FieldInfo field = typeof(FingerprintFactory).GetField("_pluginSettingsFingerprintEntries", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ return (List)field.GetValue(factory)!;
+ }
+
+ ///
+ /// Helper to write a text file synchronously. net472 doesn't have File.WriteAllTextAsync, and these
+ /// tests must build on both TFMs. The work is trivially fast and happens during one-time test setup,
+ /// so blocking the async caller is benign.
+ ///
+#pragma warning disable CA1849 // Synchronous IO blocks async caller. net472 lacks an async equivalent; trivial test-setup IO.
+ private static void WriteTextSync(string path, string text)
+ {
+ File.WriteAllText(path, text);
+ }
+#pragma warning restore CA1849
+
+ private sealed class TestPluginSettings : PluginSettings
+ {
+ }
+
+ private sealed class NoopInputHasher : IInputHasher
+ {
+ public bool ContainsPath(string absolutePath) => false;
+
+ public ValueTask GetHashAsync(string absolutePath) => new((byte[]?)null);
+ }
+
+ ///
+ /// IInputHasher backed by a dictionary of (absolute path → content hash). Returns null for paths not
+ /// in the dictionary, matching the contract for "path not known to the hasher".
+ ///
+ private sealed class DictInputHasher : IInputHasher
+ {
+ private readonly IReadOnlyDictionary _hashes;
+
+ public DictInputHasher(IReadOnlyDictionary hashes)
+ {
+ _hashes = hashes;
+ }
+
+ public bool ContainsPath(string absolutePath) => _hashes.ContainsKey(absolutePath);
+
+ public ValueTask GetHashAsync(string absolutePath)
+ {
+ return new ValueTask(_hashes.TryGetValue(absolutePath, out byte[]? hash) ? hash : null);
+ }
+ }
+
+ ///
+ /// Disposable temporary directory for filesystem-backed tests. Created under the system temp root with a
+ /// unique GUID suffix; cleaned up on Dispose even if the test throws.
+ ///
+ private sealed class TempDirectory : IDisposable
+ {
+ public string Path { get; }
+
+ private TempDirectory(string path)
+ {
+ Path = path;
+ }
+
+ public static TempDirectory Create(string prefix)
+ {
+ string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(path);
+ return new TempDirectory(path);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(Path))
+ {
+ try
+ {
+ Directory.Delete(Path, recursive: true);
+ }
+ catch
+ {
+ // Best-effort cleanup; don't mask the underlying test failure if any.
+ }
+ }
+ }
+ }
+}
diff --git a/src/Common.Tests/ObservationFilterTests.cs b/src/Common.Tests/ObservationFilterTests.cs
new file mode 100644
index 0000000..a51496c
--- /dev/null
+++ b/src/Common.Tests/ObservationFilterTests.cs
@@ -0,0 +1,227 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Build.Experimental.FileAccess;
+using Microsoft.MSBuildCache.FileAccess;
+using Microsoft.MSBuildCache.Fingerprinting;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.MSBuildCache.Tests;
+
+[TestClass]
+public class ObservationFilterTests
+{
+ [TestMethod]
+ [DataRow(0u, ObservationType.ExistingProbe, DisplayName = "success")]
+ [DataRow(2u, ObservationType.AbsentPathProbe, DisplayName = "ERROR_FILE_NOT_FOUND")]
+ [DataRow(3u, ObservationType.AbsentPathProbe, DisplayName = "ERROR_PATH_NOT_FOUND")]
+ [DataRow(53u, ObservationType.AbsentPathProbe, DisplayName = "ERROR_BAD_NETPATH")]
+ [DataRow(123u, ObservationType.AbsentPathProbe, DisplayName = "ERROR_INVALID_NAME")]
+ [DataRow(5u, ObservationType.ExistingProbe, DisplayName = "ERROR_ACCESS_DENIED is transient, not absence")]
+ [DataRow(32u, ObservationType.ExistingProbe, DisplayName = "ERROR_SHARING_VIOLATION is transient, not absence")]
+ [DataRow(1224u, ObservationType.ExistingProbe, DisplayName = "unrecognized error is not absence")]
+ public void ProbeErrorClassification(uint error, ObservationType expected)
+ {
+ Assert.AreEqual(
+ expected,
+ FileAccessRepository.ClassifyObservation(RequestedAccess.Probe, error),
+ "Only definitive not-found codes may classify as absent. Mapping a transient failure such as "
+ + "ERROR_SHARING_VIOLATION or ERROR_ACCESS_DENIED to AbsentPathProbe would let machine flakiness "
+ + "change the PathSet and lose cache hits.");
+ }
+
+ ///
+ /// is reported against the directory being enumerated, but
+ /// is reported against each matched child. Recording the
+ /// latter as a directory enumeration would key the entry on a file path, and since lookup-time
+ /// validation requires the path to still be a directory, such an entry could never re-validate — every
+ /// build using FindFirstFileEx/FindNextFile enumeration would permanently miss.
+ ///
+ [TestMethod]
+ public void EnumerationProbeIsAProbeNotADirectoryEnumeration()
+ {
+ Assert.AreEqual(
+ ObservationType.DirectoryEnumeration,
+ FileAccessRepository.ClassifyObservation(RequestedAccess.Enumerate, 0),
+ "Enumerate is reported on the directory itself and carries the search pattern.");
+
+ Assert.AreEqual(
+ ObservationType.ExistingProbe,
+ FileAccessRepository.ClassifyObservation(RequestedAccess.EnumerationProbe, 0),
+ "EnumerationProbe is reported per matched child, so it is an existence probe on that child.");
+ }
+
+ ///
+ /// is a flags enum, and content access must keep flowing to the normal
+ /// file-table handling rather than being short-circuited into an observation.
+ ///
+ [TestMethod]
+ [DataRow(RequestedAccess.Read, DisplayName = "Read")]
+ [DataRow(RequestedAccess.Write, DisplayName = "Write")]
+ [DataRow(RequestedAccess.ReadWrite, DisplayName = "ReadWrite")]
+ [DataRow(RequestedAccess.Read | RequestedAccess.Probe, DisplayName = "Read combined with Probe")]
+ [DataRow(RequestedAccess.None, DisplayName = "None")]
+ public void ContentAccessIsNotAnObservation(RequestedAccess requestedAccess)
+ {
+ Assert.IsNull(
+ FileAccessRepository.ClassifyObservation(requestedAccess, 0),
+ "Accesses carrying Read or Write are content accesses and must reach the file table.");
+ }
+
+ [TestMethod]
+ public void TrimTrailingSeparatorRemovesBackslash()
+ {
+ Assert.AreEqual(@"X:\foo\bar", FileAccessRepository.TrimTrailingSeparator(@"X:\foo\bar\"));
+ }
+
+ [TestMethod]
+ public void TrimTrailingSeparatorRemovesForwardSlash()
+ {
+ Assert.AreEqual("X:/foo/bar", FileAccessRepository.TrimTrailingSeparator("X:/foo/bar/"));
+ }
+
+ [TestMethod]
+ public void TrimTrailingSeparatorNoOpWhenAbsent()
+ {
+ Assert.AreEqual(@"X:\foo\bar", FileAccessRepository.TrimTrailingSeparator(@"X:\foo\bar"));
+ }
+
+ [TestMethod]
+ public void TrimTrailingSeparatorEmpty()
+ {
+ Assert.AreEqual(string.Empty, FileAccessRepository.TrimTrailingSeparator(string.Empty));
+ }
+
+ [TestMethod]
+ public void BuildEverWrittenOrAncestorSetIncludesAllAncestors()
+ {
+ HashSet result = FileAccessRepository.BuildEverWrittenOrAncestorSet(new List
+ {
+ @"X:\Repo\bin\Debug\net9.0\TestProject.dll",
+ });
+
+ // Must include the file itself plus every ancestor up to drive root.
+ Assert.IsTrue(result.Contains(@"X:\Repo\bin\Debug\net9.0\TestProject.dll"));
+ Assert.IsTrue(result.Contains(@"X:\Repo\bin\Debug\net9.0"));
+ Assert.IsTrue(result.Contains(@"X:\Repo\bin\Debug"));
+ Assert.IsTrue(result.Contains(@"X:\Repo\bin"));
+ Assert.IsTrue(result.Contains(@"X:\Repo"));
+ Assert.IsTrue(result.Contains(@"X:\"));
+ }
+
+ [TestMethod]
+ public void BuildEverWrittenOrAncestorSetDeduplicatesSharedAncestors()
+ {
+ HashSet result = FileAccessRepository.BuildEverWrittenOrAncestorSet(new List
+ {
+ @"X:\Repo\bin\Debug\net9.0\TestProject.dll",
+ @"X:\Repo\bin\Debug\net9.0\TestProject.pdb",
+ });
+
+ // Two paths share most of the ancestor chain — they shouldn't double-count.
+ // Specifically, the early-exit when an ancestor is already in the set should kick in for
+ // the second path at "X:\Repo\bin\Debug\net9.0".
+ int net9Count = result.Count(p => string.Equals(p, @"X:\Repo\bin\Debug\net9.0", StringComparison.OrdinalIgnoreCase));
+ Assert.AreEqual(1, net9Count, "Shared ancestor must appear exactly once.");
+ }
+
+ [TestMethod]
+ public void BuildEverWrittenOrAncestorSetCaseInsensitive()
+ {
+ HashSet result = FileAccessRepository.BuildEverWrittenOrAncestorSet(new List
+ {
+ @"X:\Repo\BIN\Debug\TestProject.dll",
+ @"X:\repo\bin\debug\Other.dll",
+ });
+
+ // Case-insensitive comparison: both files contribute their ancestor chains, but the chains
+ // share the same logical entries (just different casings).
+ // Both files plus shared ancestor chain @ "X:\Repo\BIN\Debug" + "X:\Repo\BIN" + "X:\Repo" + "X:\"
+ // First write's ancestors get added with their casing; second write's ancestors are deduped via
+ // OrdinalIgnoreCase.
+ Assert.AreEqual(6, result.Count);
+ }
+
+ [TestMethod]
+ public void BuildEverWrittenOrAncestorSetTrimsTrailingSeparator()
+ {
+ // Caller-supplied paths may have a trailing separator (e.g., directory writes recorded as
+ // "X:\Repo\bin\Debug\net9.0\"). BuildEverWrittenOrAncestorSet must trim once on entry so the
+ // ancestor walk does not produce an off-by-one parent of the same logical directory.
+ HashSet result = FileAccessRepository.BuildEverWrittenOrAncestorSet(new List
+ {
+ @"X:\Repo\bin\Debug\net9.0\",
+ });
+
+ Assert.IsTrue(result.Contains(@"X:\Repo\bin\Debug\net9.0"));
+ Assert.IsFalse(result.Contains(@"X:\Repo\bin\Debug\net9.0\"));
+ Assert.IsTrue(result.Contains(@"X:\Repo\bin\Debug"));
+ Assert.IsTrue(result.Contains(@"X:\Repo\bin"));
+ Assert.IsTrue(result.Contains(@"X:\Repo"));
+ Assert.IsTrue(result.Contains(@"X:\"));
+ }
+
+ [TestMethod]
+ public void BuildEverWrittenOrAncestorSetEmptyInput()
+ {
+ HashSet result = FileAccessRepository.BuildEverWrittenOrAncestorSet(new List());
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [TestMethod]
+ public void SelfOutputProbeIsExcluded()
+ {
+ HashSet everWrittenOrAncestor = FileAccessRepository.BuildEverWrittenOrAncestorSet(new List
+ {
+ @"X:\Repo\staging\Generated.dll",
+ });
+ HashSet everWritten = new(StringComparer.OrdinalIgnoreCase)
+ {
+ @"X:\Repo\staging\Generated.dll",
+ };
+
+ var observation = new ObservedAccess(@"X:\Repo\staging", ObservationType.ExistingProbe);
+
+ Assert.IsTrue(FileAccessRepository.ShouldExcludeSelfOutputObservation(observation, everWritten, everWrittenOrAncestor));
+ }
+
+ [TestMethod]
+ public void SelfOutputDirectoryEnumerationIsRetainedForPartitioning()
+ {
+ HashSet everWrittenOrAncestor = FileAccessRepository.BuildEverWrittenOrAncestorSet(new List
+ {
+ @"X:\Repo\staging\Generated.dll",
+ });
+ HashSet everWritten = new(StringComparer.OrdinalIgnoreCase)
+ {
+ @"X:\Repo\staging\Generated.dll",
+ };
+
+ var observation = new ObservedAccess(@"X:\Repo\staging", ObservationType.DirectoryEnumeration);
+
+ Assert.IsFalse(
+ FileAccessRepository.ShouldExcludeSelfOutputObservation(observation, everWritten, everWrittenOrAncestor),
+ "Directory enumerations must reach PartitionDirectoryMembers so external members remain fingerprint dependencies.");
+ }
+
+ [TestMethod]
+ public void SelfCreatedDirectoryEnumerationIsExcluded()
+ {
+ var writtenPaths = new List
+ {
+ @"X:\Repo\obj\generated",
+ @"X:\Repo\obj\generated\Generated.gen",
+ };
+ HashSet everWrittenOrAncestor = FileAccessRepository.BuildEverWrittenOrAncestorSet(writtenPaths);
+ HashSet everWritten = new(writtenPaths, StringComparer.OrdinalIgnoreCase);
+
+ var observation = new ObservedAccess(@"X:\Repo\obj\generated", ObservationType.DirectoryEnumeration);
+
+ Assert.IsTrue(
+ FileAccessRepository.ShouldExcludeSelfOutputObservation(observation, everWritten, everWrittenOrAncestor),
+ "A directory created by the project has no pre-build membership state to validate at lookup.");
+ }
+}
diff --git a/src/Common.Tests/PathNormalizerTests.cs b/src/Common.Tests/PathNormalizerTests.cs
new file mode 100644
index 0000000..25e0f35
--- /dev/null
+++ b/src/Common.Tests/PathNormalizerTests.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.MSBuildCache.Tests;
+
+[TestClass]
+public class PathNormalizerTests
+{
+ ///
+ /// Repo-rooted paths are normalized after .
+ ///
+ [TestMethod]
+ public void IsNormalizedRepoRoot()
+ {
+ var pn = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+ string normalized = pn.Normalize(@"X:\Repo\src\foo.cs");
+
+ Assert.IsTrue(PathNormalizer.IsNormalized(normalized), $"Expected '{normalized}' to be normalized (under repo root).");
+ }
+
+ ///
+ /// NuGet-rooted paths are normalized after .
+ ///
+ [TestMethod]
+ public void IsNormalizedNugetRoot()
+ {
+ var pn = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+ string normalized = pn.Normalize(@"X:\Nuget\some.package\1.0.0\lib\foo.dll");
+
+ Assert.IsTrue(PathNormalizer.IsNormalized(normalized), $"Expected '{normalized}' to be normalized (under NuGet root).");
+ }
+
+ ///
+ /// System paths (drives outside repo+NuGet) are not normalized. This is the rule that filters
+ /// .NET Framework breadcrumb stores, NGEN caches, MSBuild SDK installs, etc.
+ ///
+ [TestMethod]
+ public void IsNormalizedSystemPathFails()
+ {
+ var pn = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+ string normalized = pn.Normalize(@"C:\ProgramData\Microsoft\NetFramework\BreadcrumbStore\some.breadcrumb");
+
+ Assert.IsFalse(PathNormalizer.IsNormalized(normalized), $"Expected '{normalized}' to NOT be normalized (system path).");
+ }
+
+ ///
+ /// Same drive as the repo but outside the repo root is still not normalized.
+ ///
+ [TestMethod]
+ public void IsNormalizedSameDriveOutsideRepoFails()
+ {
+ var pn = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+ string normalized = pn.Normalize(@"X:\OtherProject\foo.cs");
+
+ Assert.IsFalse(PathNormalizer.IsNormalized(normalized), $"Expected '{normalized}' to NOT be normalized (sibling of repo root).");
+ }
+
+ ///
+ /// Round-trip: Normalize then Unnormalize returns the original path (or an OS-equivalent form).
+ /// Pinned because the scope check depends on Normalize producing a placeholder prefix for in-scope paths.
+ ///
+ [TestMethod]
+ public void NormalizeUnnormalizeRoundTrip()
+ {
+ var pn = new PathNormalizer(@"X:\Repo", @"X:\Nuget");
+ string original = @"X:\Repo\src\Program.cs";
+
+ string normalized = pn.Normalize(original);
+ string roundTripped = pn.Unnormalize(normalized);
+
+ Assert.AreEqual(original, roundTripped);
+ Assert.IsTrue(normalized.StartsWith("{RepoRoot}", System.StringComparison.Ordinal));
+ }
+}
diff --git a/src/Common.Tests/PathSetTests.cs b/src/Common.Tests/PathSetTests.cs
new file mode 100644
index 0000000..47bbbeb
--- /dev/null
+++ b/src/Common.Tests/PathSetTests.cs
@@ -0,0 +1,302 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Microsoft.MSBuildCache.Fingerprinting;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.MSBuildCache.Tests;
+
+[TestClass]
+public class PathSetTests
+{
+ ///
+ /// A payload without an Entries property — written by a version predating this schema, or
+ /// truncated — must degrade to a cache miss rather than throwing. is used as a
+ /// dictionary key during cache lookup, so it is hashed before any caller can inspect it; a null
+ /// Entries would throw from and take down the build,
+ /// bypassing the null handling in the fingerprint factory entirely.
+ ///
+ [TestMethod]
+ public void DeserializingPayloadWithoutEntriesDoesNotThrow()
+ {
+ const string NoEntriesPayload = @"{""FilesRead"":[""dir/a.cs"",""dir/b.cs""]}";
+
+ PathSet? deserialized = JsonSerializer.Deserialize(NoEntriesPayload, SourceGenerationContext.Default.PathSet);
+
+ Assert.IsNotNull(deserialized);
+ Assert.IsNotNull(deserialized!.Entries, "Entries must never be null; it is hashed before it can be checked.");
+ Assert.AreEqual(0, deserialized.Entries.Count);
+
+ // Both must be callable, since the type is used as a cache key.
+ _ = deserialized.GetHashCode();
+ Assert.IsTrue(deserialized.Equals(new PathSet(new List())));
+ }
+
+ [TestMethod]
+ public void EqualsIdenticalEntriesWithNullPattern()
+ {
+ // Regression: pre-Phase-1 the path was stored as a raw string; the new schema permits a null EnumerationPattern.
+ // Equality and hashing must tolerate null on both sides without throwing.
+ var a = new PathSet(new List
+ {
+ new("dir/file.cs", ObservationType.FileContentRead),
+ });
+ var b = new PathSet(new List
+ {
+ new("dir/file.cs", ObservationType.FileContentRead),
+ });
+
+ Assert.IsTrue(a.Equals(b));
+ Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [TestMethod]
+ public void EqualsIdenticalEntries()
+ {
+ var a = new PathSet(new List
+ {
+ new("dir/file.cs", ObservationType.FileContentRead),
+ new("dir2/", ObservationType.DirectoryEnumeration, "*.cs"),
+ });
+ var b = new PathSet(new List
+ {
+ new("DIR/FILE.CS", ObservationType.FileContentRead), // case-insensitive path
+ new("dir2/", ObservationType.DirectoryEnumeration, "*.cs"),
+ });
+
+ Assert.IsTrue(a.Equals(b));
+ Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [TestMethod]
+ public void EqualsDifferingType()
+ {
+ var a = new PathSet(new List
+ {
+ new("file", ObservationType.FileContentRead),
+ });
+ var b = new PathSet(new List
+ {
+ new("file", ObservationType.ExistingProbe),
+ });
+
+ Assert.IsFalse(a.Equals(b));
+ }
+
+ [TestMethod]
+ public void EqualsDifferingPattern()
+ {
+ var a = new PathSet(new List
+ {
+ new("dir", ObservationType.DirectoryEnumeration, "*.cs"),
+ });
+ var b = new PathSet(new List
+ {
+ new("dir", ObservationType.DirectoryEnumeration, "*.fs"),
+ });
+
+ Assert.IsFalse(a.Equals(b));
+ }
+
+ [TestMethod]
+ public void EqualsDifferingPatternCase()
+ {
+ // Pattern equality is ordinal — a build that enumerated for "*.CS" is semantically distinct from one that
+ // enumerated for "*.cs" (the underlying enumeration API may or may not be case-insensitive at the filesystem
+ // layer, but the strong fingerprint must be deterministic in the pattern string itself).
+ var a = new PathSet(new List
+ {
+ new("dir", ObservationType.DirectoryEnumeration, "*.cs"),
+ });
+ var b = new PathSet(new List
+ {
+ new("dir", ObservationType.DirectoryEnumeration, "*.CS"),
+ });
+
+ Assert.IsFalse(a.Equals(b));
+ }
+
+ [TestMethod]
+ public void EqualsOrderMatters()
+ {
+ // PathSet equality is positional. Callers are responsible for canonical sort before construction.
+ var a = new PathSet(new List
+ {
+ new("a", ObservationType.FileContentRead),
+ new("b", ObservationType.FileContentRead),
+ });
+ var b = new PathSet(new List
+ {
+ new("b", ObservationType.FileContentRead),
+ new("a", ObservationType.FileContentRead),
+ });
+
+ Assert.IsFalse(a.Equals(b));
+ }
+
+ [TestMethod]
+ public void JsonRoundTripAllObservationTypes()
+ {
+ var original = new PathSet(new List
+ {
+ new("repo/src/foo.cs", ObservationType.FileContentRead),
+ new("repo/src/", ObservationType.DirectoryEnumeration, "*.cs"),
+ new("repo/bin/probed.dll", ObservationType.ExistingProbe),
+ new("repo/obj/missing.gen.cs", ObservationType.AbsentPathProbe),
+ });
+
+ string serialized = JsonSerializer.Serialize(original, SourceGenerationContext.Default.PathSet);
+ PathSet? deserialized = JsonSerializer.Deserialize(serialized, SourceGenerationContext.Default.PathSet);
+
+ Assert.IsNotNull(deserialized);
+ Assert.AreEqual(original, deserialized);
+ // Sanity-check that every type round-tripped: equality covers it, but be explicit about the schema field.
+ Assert.AreEqual(4, deserialized!.Entries.Count);
+ Assert.AreEqual(ObservationType.FileContentRead, deserialized.Entries[0].Type);
+ Assert.AreEqual(ObservationType.DirectoryEnumeration, deserialized.Entries[1].Type);
+ Assert.AreEqual("*.cs", deserialized.Entries[1].EnumerationPattern);
+ Assert.AreEqual(ObservationType.ExistingProbe, deserialized.Entries[2].Type);
+ Assert.AreEqual(ObservationType.AbsentPathProbe, deserialized.Entries[3].Type);
+ }
+
+ [TestMethod]
+ public void ObservationTypeByteValuesAreStable()
+ {
+ // Schema stability: these byte values are part of the on-disk schema and contribute to strong fingerprints.
+ // The QuickBuild implementation locks the same numeric values; changing either side risks
+ // semantic divergence in cross-system diagnostics or shared cache scenarios.
+ Dictionary expected = new()
+ {
+ { ObservationType.FileContentRead, 1 },
+ { ObservationType.DirectoryEnumeration, 2 },
+ { ObservationType.ExistingProbe, 3 },
+ { ObservationType.AbsentPathProbe, 4 },
+ };
+
+ foreach (KeyValuePair kvp in expected)
+ {
+ Assert.AreEqual(kvp.Value, (byte)kvp.Key, $"Byte value of {kvp.Key} changed; this would invalidate every PathSet on disk.");
+ }
+ }
+
+ [TestMethod]
+ public void ObservationTypePrecedenceOrdering()
+ {
+ // Lock the precedence order: FileContentRead > DirectoryEnumeration > ExistingProbe > AbsentPathProbe.
+ // This is checked against the QuickBuild implementation; both systems must agree.
+ ObservationType[] descendingPrecedence =
+ {
+ ObservationType.FileContentRead,
+ ObservationType.DirectoryEnumeration,
+ ObservationType.ExistingProbe,
+ ObservationType.AbsentPathProbe,
+ };
+
+ // Pairwise: every left-of-right pair must yield the left value as Max (higher precedence wins).
+ for (int i = 0; i < descendingPrecedence.Length; i++)
+ {
+ for (int j = i + 1; j < descendingPrecedence.Length; j++)
+ {
+ ObservationType higher = descendingPrecedence[i];
+ ObservationType lower = descendingPrecedence[j];
+ Assert.AreEqual(higher, ObservationTypePrecedence.Max(higher, lower), $"Max({higher}, {lower})");
+ Assert.AreEqual(higher, ObservationTypePrecedence.Max(lower, higher), $"Max({lower}, {higher}) (commutative)");
+ }
+ }
+
+ // Identity: Max(x, x) == x for all x.
+ foreach (ObservationType t in descendingPrecedence)
+ {
+ Assert.AreEqual(t, ObservationTypePrecedence.Max(t, t));
+ }
+ }
+
+ [TestMethod]
+ public void EnumerationPatternNullForNonDirectoryEnumeration()
+ {
+ // Pattern is meaningful only for DirectoryEnumeration. The constructor normalizes anything else to null
+ // so that semantically-equivalent observations compare equal regardless of pattern threading.
+ Assert.IsNull(new ObservedPathEntry("p", ObservationType.FileContentRead, "*.cs").EnumerationPattern);
+ Assert.IsNull(new ObservedPathEntry("p", ObservationType.ExistingProbe, "*.cs").EnumerationPattern);
+ Assert.IsNull(new ObservedPathEntry("p", ObservationType.AbsentPathProbe, "*.cs").EnumerationPattern);
+ Assert.AreEqual("*.cs", new ObservedPathEntry("p", ObservationType.DirectoryEnumeration, "*.cs").EnumerationPattern);
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // Schema: DirectoryEnumeration Members + WrittenMembers.
+ // -----------------------------------------------------------------------------------------
+
+ [TestMethod]
+ public void EqualsIdenticalDirectoryEnumerationWithMembers()
+ {
+ var a = new ObservedPathEntry("dir/", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs", "b.cs" },
+ writtenMembers: new[] { "Foo.dll" });
+ var b = new ObservedPathEntry("dir/", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs", "b.cs" },
+ writtenMembers: new[] { "Foo.dll" });
+
+ Assert.IsTrue(a.Equals(b));
+ Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
+ }
+
+ [TestMethod]
+ public void NotEqualWhenMembersDiffer()
+ {
+ var a = new ObservedPathEntry("dir/", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs" },
+ writtenMembers: null);
+ var b = new ObservedPathEntry("dir/", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs", "b.cs" },
+ writtenMembers: null);
+
+ Assert.IsFalse(a.Equals(b));
+ }
+
+ [TestMethod]
+ public void NotEqualWhenWrittenMembersDiffer()
+ {
+ var a = new ObservedPathEntry("dir/", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs" },
+ writtenMembers: new[] { "Foo.dll" });
+ var b = new ObservedPathEntry("dir/", ObservationType.DirectoryEnumeration, enumerationPattern: null,
+ members: new[] { "a.cs" },
+ writtenMembers: new[] { "Bar.dll" });
+
+ Assert.IsFalse(a.Equals(b));
+ }
+
+ [TestMethod]
+ public void MembersAndWrittenMembersOnlyApplyToDirectoryEnumeration()
+ {
+ // The constructor normalizes Members and WrittenMembers to null on non-DirEnum types,
+ // mirroring EnumerationPattern's normalization. Ensures semantically-equivalent observations
+ // compare equal regardless of whether a stray Members value was threaded through.
+ var fcr = new ObservedPathEntry("p", ObservationType.FileContentRead, enumerationPattern: "*.cs",
+ members: new[] { "should-be-ignored" },
+ writtenMembers: new[] { "should-be-ignored" });
+
+ Assert.IsNull(fcr.Members);
+ Assert.IsNull(fcr.WrittenMembers);
+ Assert.IsNull(fcr.EnumerationPattern);
+ }
+
+ [TestMethod]
+ public void JsonRoundTripPreservesMembersAndWrittenMembers()
+ {
+ var original = new PathSet(new List
+ {
+ new("dir/", ObservationType.DirectoryEnumeration, enumerationPattern: "*.cs",
+ members: new[] { "a.cs", "b.cs" },
+ writtenMembers: new[] { "Foo.dll", "Foo.pdb" }),
+ });
+
+ string json = JsonSerializer.Serialize(original, SourceGenerationContext.Default.PathSet);
+ PathSet roundTripped = JsonSerializer.Deserialize(json, SourceGenerationContext.Default.PathSet)!;
+
+ Assert.IsTrue(original.Equals(roundTripped),
+ $"PathSet should round-trip through JSON serialization. Original entries: {original.Entries.Count}, deserialized entries: {roundTripped.Entries.Count}.");
+ }
+}
diff --git a/src/Common.Tests/PluginSettingsExtensibilityTests.cs b/src/Common.Tests/PluginSettingsExtensibilityTests.cs
index 5887045..073bf2e 100644
--- a/src/Common.Tests/PluginSettingsExtensibilityTests.cs
+++ b/src/Common.Tests/PluginSettingsExtensibilityTests.cs
@@ -23,7 +23,10 @@ public void EffectiveSettingsLogging()
{
Dictionary settings = new(StringComparer.OrdinalIgnoreCase);
MockPluginLogger logger = new();
- _ = PluginSettings.Create(settings, logger, RepoRoot);
+
+ // Pin the capability so this stays a test of settings logging; without it the probe-and-enumeration
+ // clamp would log a second entry whenever the test host's MSBuild predates the required fields.
+ _ = PluginSettings.Create(settings, logger, RepoRoot, supportsProbeAndEnumerationCapture: true);
Assert.HasCount(1, logger.LogEntries);
@@ -52,7 +55,11 @@ public void DefaultValue()
{
Dictionary settings = new(StringComparer.OrdinalIgnoreCase);
MockPluginLogger logger = new();
- MockPluginSettings pluginSettings = PluginSettings.Create(settings, logger, RepoRoot);
+ MockPluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ logger,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
Assert.AreEqual(DefaultMockPluginSettings.StringSetting, pluginSettings.StringSetting);
@@ -119,7 +126,11 @@ public void InvalidValues()
{ nameof(DefaultMockPluginSettings.ISetSetting), "InvalidValue" },
};
MockPluginLogger logger = new();
- MockPluginSettings pluginSettings = PluginSettings.Create(settings, logger, RepoRoot);
+ MockPluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ logger,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
AssertInvalidValueHandled(nameof(MockPluginSettings.EnumSetting), pluginSettings => pluginSettings.EnumSetting);
@@ -196,7 +207,11 @@ public void ExplicitValues()
{ nameof(DefaultMockPluginSettings.ISetSetting), "4; 5; 6" },
};
MockPluginLogger logger = new();
- MockPluginSettings pluginSettings = PluginSettings.Create(settings, logger, RepoRoot);
+ MockPluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ logger,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
Assert.AreEqual("B", pluginSettings.StringSetting);
@@ -241,7 +256,12 @@ public void UnsupportedTypeSetting()
{ nameof(MockPluginSettingsWithUnsupportedType.UnsupportedTypeSetting), "Baz" },
};
MockPluginLogger logger = new();
- MockPluginSettingsWithUnsupportedType pluginSettings = PluginSettings.Create(settings, logger, RepoRoot);
+ MockPluginSettingsWithUnsupportedType pluginSettings =
+ PluginSettings.Create(
+ settings,
+ logger,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
AssertNotLogged(logger, PluginLogLevel.Warning, "has invalid value");
AssertLogged(logger, PluginLogLevel.Warning, "has unsupported type");
diff --git a/src/Common.Tests/PluginSettingsTests.cs b/src/Common.Tests/PluginSettingsTests.cs
index d7024fa..27615ad 100644
--- a/src/Common.Tests/PluginSettingsTests.cs
+++ b/src/Common.Tests/PluginSettingsTests.cs
@@ -6,6 +6,8 @@
using System.Linq;
using System.Reflection;
using DotNet.Globbing;
+using Microsoft.Build.Evaluation;
+using Microsoft.MSBuildCache.FileAccess;
using Microsoft.MSBuildCache.Tests.Mocks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -23,7 +25,10 @@ public void EffectiveSettingsLogging()
{
Dictionary settings = new(StringComparer.OrdinalIgnoreCase);
MockPluginLogger logger = new();
- _ = PluginSettings.Create(settings, logger, RepoRoot);
+
+ // Pin the capability so this stays a test of settings logging; without it the probe-and-enumeration
+ // clamp would log a second entry whenever the test host's MSBuild predates the required fields.
+ _ = PluginSettings.Create(settings, logger, RepoRoot, supportsProbeAndEnumerationCapture: true);
Assert.HasCount(1, logger.LogEntries);
@@ -61,7 +66,11 @@ public void LogDirectorySetting(string? logDirectorySetting, string expectedLogD
settings.Add(nameof(PluginSettings.LogDirectory), logDirectorySetting);
}
- PluginSettings pluginSettings = PluginSettings.Create(settings, NullPluginLogger.Instance, RepoRoot);
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ NullPluginLogger.Instance,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
Assert.AreEqual(expectedLogDirectory, pluginSettings.LogDirectory);
}
@@ -94,6 +103,99 @@ public void LocalCacheSizeInMegabytesSetting()
pluginSettings => pluginSettings.LocalCacheSizeInMegabytes,
new[] { 123u, 456u, 789u });
+ // =========================================================================================
+ // EnableProbeAndEnumerationFingerprinting capability clamp.
+ //
+ // Probe and enumeration observations are only sound when the running MSBuild reports the
+ // enumeration pattern. On an older MSBuild the setting is forced to false so that the property
+ // itself is always the single source of truth — including for the weak fingerprint, which is what
+ // keeps caches from being shared between hosts that do and don't capture these observations.
+ // =========================================================================================
+
+ [TestMethod]
+ [DataRow(true, DisplayName = "explicitly requested")]
+ [DataRow(false, DisplayName = "left at default")]
+ public void ProbeAndEnumerationFingerprintingForcedOffWithoutCapability(bool requestExplicitly)
+ {
+ Dictionary settings = new(StringComparer.OrdinalIgnoreCase);
+ if (requestExplicitly)
+ {
+ settings[nameof(PluginSettings.EnableProbeAndEnumerationFingerprinting)] = "true";
+ }
+
+ MockPluginLogger logger = new();
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ logger,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: false);
+
+ Assert.IsFalse(
+ pluginSettings.EnableProbeAndEnumerationFingerprinting,
+ "The setting must be forced off when the host MSBuild cannot report the required file access fields, "
+ + "even when the user explicitly asked for it.");
+
+ Assert.IsTrue(
+ logger.LogEntries.Any(entry => entry.Message.Contains(
+ nameof(PluginSettings.EnableProbeAndEnumerationFingerprinting), StringComparison.Ordinal)
+ && entry.Message.Contains("forced to false", StringComparison.Ordinal)),
+ "Forcing the setting off must be logged so the cache-behavior change is diagnosable.");
+
+ // Naming the running version is what makes the message actionable — otherwise a user is told the
+ // feature is off but not what they are on or that upgrading would fix it.
+ if (FileAccessDataCapabilities.MSBuildVersion is string msbuildVersion)
+ {
+ Assert.IsTrue(
+ logger.LogEntries.Any(entry => entry.Message.Contains(msbuildVersion, StringComparison.Ordinal)),
+ $"The message must name the running MSBuild version ('{msbuildVersion}').");
+ }
+ }
+
+ [TestMethod]
+ public void MSBuildVersionUsesAssemblyInformationalVersion()
+ {
+ string? expected = typeof(ProjectCollection).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion;
+
+ Assert.AreEqual(expected, FileAccessDataCapabilities.MSBuildVersion);
+ }
+
+ [TestMethod]
+ public void ProbeAndEnumerationFingerprintingHonoredWithCapability()
+ {
+ Dictionary settings = new(StringComparer.OrdinalIgnoreCase);
+ MockPluginLogger logger = new();
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ logger,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
+
+ Assert.IsTrue(pluginSettings.EnableProbeAndEnumerationFingerprinting);
+ }
+
+ ///
+ /// The clamp must not resurrect the feature for a user who explicitly turned it off on a capable host.
+ ///
+ [TestMethod]
+ public void ProbeAndEnumerationFingerprintingRespectsExplicitOptOutWithCapability()
+ {
+ Dictionary settings = new(StringComparer.OrdinalIgnoreCase)
+ {
+ [nameof(PluginSettings.EnableProbeAndEnumerationFingerprinting)] = "false",
+ };
+
+ MockPluginLogger logger = new();
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ logger,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
+
+ Assert.IsFalse(pluginSettings.EnableProbeAndEnumerationFingerprinting);
+ }
+
[TestMethod]
[DynamicData(nameof(GlobTestCases), DynamicDataDisplayName = nameof(GetTestCaseDisplayName))]
public void IgnoredInputPatternsSetting(GlobTestCase testCase)
@@ -146,6 +248,32 @@ public void AllowFileAccessAfterProjectFinishFilePatternsSetting(GlobTestCase te
testCase,
pluginSettings => pluginSettings.AllowFileAccessAfterProjectFinishFilePatterns);
+ [TestMethod]
+ public void AllowFileAccessAfterProjectFinishFilePatternsSupportsMachineLocalCategories()
+ {
+ Dictionary settings = new(StringComparer.OrdinalIgnoreCase)
+ {
+ [nameof(PluginSettings.AllowFileAccessAfterProjectFinishFilePatterns)] =
+ @"\**\ApplicationInsights.config;" +
+ @"C:\Users\Test\AppData\Local\Microsoft\VSApplicationInsights\**;" +
+ @"C:\Users\Test\AppData\Local\Microsoft\Windows\INetCache\**;" +
+ @"C:\Windows\**",
+ };
+
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ NullPluginLogger.Instance,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
+
+ IReadOnlyCollection patterns = pluginSettings.AllowFileAccessAfterProjectFinishFilePatterns;
+ Assert.IsTrue(patterns.Any(pattern => pattern.IsMatch(@"C:\Program Files\Telemetry\ApplicationInsights.config")));
+ Assert.IsTrue(patterns.Any(pattern => pattern.IsMatch(@"C:\Users\Test\AppData\Local\Microsoft\VSApplicationInsights\config.json")));
+ Assert.IsTrue(patterns.Any(pattern => pattern.IsMatch(@"C:\Users\Test\AppData\Local\Microsoft\Windows\INetCache\IE\ABC\dyntelconfig[2].cache")));
+ Assert.IsTrue(patterns.Any(pattern => pattern.IsMatch(@"C:\Windows\System32\ci.dll")));
+ Assert.IsFalse(patterns.Any(pattern => pattern.IsMatch(@"X:\Repo\src\Program.cs")));
+ }
+
[TestMethod]
[DynamicData(nameof(GlobTestCases), DynamicDataDisplayName = nameof(GetTestCaseDisplayName))]
public void AllowProcessCloseAfterProjectFinishProcessPatternsSetting(GlobTestCase testCase)
@@ -202,7 +330,11 @@ void TestBasicSettingValue(string? settingValue, T expectedValue)
settings.Add(settingName, settingValue);
}
- PluginSettings pluginSettings = PluginSettings.Create(settings, NullPluginLogger.Instance, RepoRoot);
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ NullPluginLogger.Instance,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
Assert.AreEqual(expectedValue, valueAccessor(pluginSettings));
}
@@ -218,7 +350,11 @@ private static void TestGlobListSetting(
{ settingName, testCase.Glob },
};
- PluginSettings pluginSettings = PluginSettings.Create(settings, NullPluginLogger.Instance, RepoRoot);
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ NullPluginLogger.Instance,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
foreach (string path in testCase.ExpectedMatching)
{
@@ -255,7 +391,11 @@ private static void TestStringListSetting(
settings.Add(settingName, testCase.SettingValue);
}
- PluginSettings pluginSettings = PluginSettings.Create(settings, NullPluginLogger.Instance, RepoRoot);
+ PluginSettings pluginSettings = PluginSettings.Create(
+ settings,
+ NullPluginLogger.Instance,
+ RepoRoot,
+ supportsProbeAndEnumerationCapture: true);
CollectionAssert.AreEqual(testCase.ExpectedValues.ToList(), valueAccessor(pluginSettings).ToList());
}
diff --git a/src/Common/Caching/CacheClient.cs b/src/Common/Caching/CacheClient.cs
index 62a906b..11407e0 100644
--- a/src/Common/Caching/CacheClient.cs
+++ b/src/Common/Caching/CacheClient.cs
@@ -575,7 +575,16 @@ async Task PlaceFilesAsync(CancellationToken ct)
continue;
}
- // Create a strong fingerprint from the PathSet and see if it matches the selector's strong fingerprint.
+ // Required for correctness, not just speed: probe and enumeration entries hash their recorded
+ // state into the strong fingerprint rather than anything read from disk, so the comparison below
+ // cannot detect that they no longer hold. This check is what enforces them. The fingerprint
+ // comparison still covers file-content changes.
+ if (!_fingerprintFactory.MatchesCurrentState(pathSet))
+ {
+ Tracer.Debug(context, $"Skipping selector with PathSet hash {pathSetHash}. Probes/enumerations no longer match current filesystem state.");
+ continue;
+ }
+
Fingerprint? possibleStrongFingerprint = await _fingerprintFactory.GetStrongFingerprintAsync(pathSet);
if (possibleStrongFingerprint != null && ByteArrayComparer.ArraysEqual(possibleStrongFingerprint.Hash, selectorStrongFingerprint))
{
diff --git a/src/Common/FileAccess/ByRefGetterFactory.cs b/src/Common/FileAccess/ByRefGetterFactory.cs
new file mode 100644
index 0000000..a91c662
--- /dev/null
+++ b/src/Common/FileAccess/ByRefGetterFactory.cs
@@ -0,0 +1,64 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.Reflection;
+
+namespace Microsoft.MSBuildCache.FileAccess;
+
+///
+/// Reads a property from a struct without boxing it. Instance members on a value type take their
+/// receiver by reference, so an open-instance delegate bound to a struct property getter must too.
+///
+internal delegate TResult ByRefGetter(ref TStruct instance)
+ where TStruct : struct;
+
+///
+/// Binds open-instance getters for properties that may not exist on the running assembly.
+///
+///
+/// MSBuildCache compiles against a reference assembly but runs against whatever Microsoft.Build.dll
+/// the host MSBuild supplies, so properties added in newer MSBuild versions cannot be called directly —
+/// they would not compile against the reference assembly, and would throw
+/// on older hosts if they did. Binding a delegate once and reusing
+/// it keeps the per-access cost to roughly a virtual call, which matters because file-access reporting
+/// is a very hot path.
+///
+internal static class ByRefGetterFactory
+{
+ ///
+ /// Binds a getter for , or returns null if the property does
+ /// not exist or does not have type — i.e. the running assembly
+ /// predates the property.
+ ///
+ public static ByRefGetter? TryCreate(string propertyName)
+ where TStruct : struct
+ {
+ PropertyInfo? property = typeof(TStruct).GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
+ if (property is null || property.PropertyType != typeof(TResult))
+ {
+ return null;
+ }
+
+ MethodInfo? getter = property.GetGetMethod(nonPublic: false);
+ if (getter is null)
+ {
+ return null;
+ }
+
+ try
+ {
+#if NET9_0_OR_GREATER
+ return getter.CreateDelegate>();
+#else
+ return (ByRefGetter)getter.CreateDelegate(typeof(ByRefGetter));
+#endif
+ }
+ catch (ArgumentException)
+ {
+ // The property exists but its getter does not have the expected shape. Treat it as absent
+ // rather than failing the build.
+ return null;
+ }
+ }
+}
diff --git a/src/Common/FileAccess/FileAccessDataCapabilities.cs b/src/Common/FileAccess/FileAccessDataCapabilities.cs
new file mode 100644
index 0000000..fdf3215
--- /dev/null
+++ b/src/Common/FileAccess/FileAccessDataCapabilities.cs
@@ -0,0 +1,59 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Reflection;
+using Microsoft.Build.Evaluation;
+using Microsoft.Build.Experimental.FileAccess;
+
+namespace Microsoft.MSBuildCache.FileAccess;
+
+///
+/// Detects whether the running MSBuild reports the file-access fields that probe and enumeration
+/// fingerprinting depends on.
+///
+///
+///
+/// EnumeratePattern was added to after the MSBuild version
+/// MSBuildCache compiles against, so it is read reflectively rather than by a direct property access
+/// that would not compile — and, if the compile reference were raised, would throw on every older host.
+///
+///
+/// Without it a filtered enumeration such as *.cs is recorded as if it were unfiltered, so any
+/// unrelated file appearing in the directory invalidates the entry and the common case essentially
+/// never hits. That is why its absence disables the feature outright rather than degrading it.
+///
+///
+internal static class FileAccessDataCapabilities
+{
+ private static readonly ByRefGetter? EnumeratePatternGetter =
+ ByRefGetterFactory.TryCreate("EnumeratePattern");
+
+ ///
+ /// Whether the running MSBuild reports the enumeration pattern. Used to force
+ /// EnableProbeAndEnumerationFingerprinting off on hosts that cannot support it.
+ ///
+ public static bool IsSupported { get; } = EnumeratePatternGetter is not null;
+
+ ///
+ /// The running MSBuild's file version, for diagnostics only, or null if it cannot be
+ /// determined.
+ ///
+ ///
+ /// Deliberately not used to decide . The version that first carries the
+ /// field is not knowable while it is unreleased, and a version comparison that guessed high would
+ /// enable the feature on a host that cannot actually report patterns. Probing for the field tests
+ /// the exact thing the feature depends on, and stays correct if the field is ever serviced back to
+ /// an older branch.
+ ///
+ public static string? MSBuildVersion { get; } =
+ typeof(ProjectCollection).Assembly
+ .GetCustomAttribute()
+ ?.InformationalVersion;
+
+ ///
+ /// The search pattern for a directory enumeration, or null for an unfiltered enumeration,
+ /// a non-enumeration access, or a host that does not report it.
+ ///
+ public static string? GetEnumeratePattern(ref FileAccessData fileAccessData)
+ => EnumeratePatternGetter is null ? null : EnumeratePatternGetter(ref fileAccessData);
+}
diff --git a/src/Common/FileAccess/FileAccessRepository.cs b/src/Common/FileAccess/FileAccessRepository.cs
index 39ef67d..f3b3eb5 100644
--- a/src/Common/FileAccess/FileAccessRepository.cs
+++ b/src/Common/FileAccess/FileAccessRepository.cs
@@ -9,6 +9,7 @@
using DotNet.Globbing;
using Microsoft.Build.Experimental.FileAccess;
using Microsoft.Build.Experimental.ProjectCache;
+using Microsoft.MSBuildCache.Fingerprinting;
namespace Microsoft.MSBuildCache.FileAccess;
@@ -64,6 +65,131 @@ public FileAccesses FinishProject(NodeContext nodeContext)
private FileAccessesState GetFileAccessesState(NodeContext nodeContext)
=> _fileAccessStates.GetOrAdd(nodeContext, nodeContext => new FileAccessesState(nodeContext, _logger, _pluginSettings, _processTable));
+ ///
+ /// Builds the "ever-written or ancestor-of-written" set used to filter self-output probe
+ /// observations. For every path in , the result includes the path itself
+ /// plus every ancestor directory back to the root. This extends the self-output probe filter with
+ /// ancestor-dir handling — MSBuild commonly probes output directories (e.g., bin\Debug\net9.0\)
+ /// before creating them, and we need to suppress those probes so re-observation at cache lookup doesn't
+ /// flip them to ExistingProbe after the build creates the directory.
+ ///
+ internal static HashSet BuildEverWrittenOrAncestorSet(List writtenPaths)
+ {
+ HashSet result = new(StringComparer.OrdinalIgnoreCase);
+ foreach (string path in writtenPaths)
+ {
+ string normalizedPath = TrimTrailingSeparator(path);
+ result.Add(normalizedPath);
+
+ // Path.GetDirectoryName output is already trim-normalized (no trailing separator except at drive
+ // roots like "C:\", which then yields null on the next call and terminates the walk). So we only
+ // need to trim caller-supplied input once, not at every level.
+ string? ancestor = Path.GetDirectoryName(normalizedPath);
+ while (!string.IsNullOrEmpty(ancestor))
+ {
+ if (!result.Add(ancestor!))
+ {
+ // Already in the set — every shallower ancestor is too. Stop the walk.
+ break;
+ }
+
+ ancestor = Path.GetDirectoryName(ancestor);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Returns whether an observation should be removed because it targets a path written by this project
+ /// or an ancestor of one. Directory enumerations are retained when the project only wrote members
+ /// beneath them, so external members can be separated from self-outputs, but are removed when the
+ /// project created the directory itself.
+ ///
+ internal static bool ShouldExcludeSelfOutputObservation(
+ ObservedAccess observation,
+ HashSet everWritten,
+ HashSet everWrittenOrAncestor)
+ {
+ string normalizedPath = TrimTrailingSeparator(observation.Path);
+ return observation.Type == ObservationType.DirectoryEnumeration
+ ? everWritten.Contains(normalizedPath)
+ : everWrittenOrAncestor.Contains(normalizedPath);
+ }
+
+ ///
+ /// Trims a single trailing directory separator (forward or back slash) from the given path. No-op when
+ /// the path doesn't end with a separator.
+ ///
+ internal static string TrimTrailingSeparator(string path)
+ => path.Length > 0 && (path[path.Length - 1] == Path.DirectorySeparatorChar || path[path.Length - 1] == Path.AltDirectorySeparatorChar)
+ ? path.Substring(0, path.Length - 1)
+ : path;
+
+ // Win32 error codes that mean the probed path definitively did not exist.
+ private const uint ErrorFileNotFound = 2;
+ private const uint ErrorPathNotFound = 3;
+ private const uint ErrorBadNetPath = 53;
+ private const uint ErrorInvalidName = 123;
+
+ ///
+ /// Whether a probe's error code means the path definitively did not exist.
+ ///
+ ///
+ /// Classification is deliberately asymmetric: only these codes produce
+ /// , and everything else — including success and
+ /// transient failures such as ERROR_SHARING_VIOLATION and ERROR_ACCESS_DENIED —
+ /// produces . Treating a transient failure as absence
+ /// would let machine flakiness change the PathSet, so the same sources would fingerprint
+ /// differently between builds and lose cache hits. This matches the QuickBuild implementation,
+ /// which the observation schema is kept in sync with.
+ ///
+ internal static bool IsKnownAbsentError(uint error)
+ => error is ErrorFileNotFound or ErrorPathNotFound or ErrorInvalidName or ErrorBadNetPath;
+
+ ///
+ /// Classifies a reported access as a probe or directory-enumeration observation, or null when
+ /// it is neither and should flow to the normal content-access handling.
+ ///
+ ///
+ ///
+ /// is a enum. An access carrying
+ /// or is content access and is
+ /// never reclassified as an observation.
+ ///
+ ///
+ /// is reported against the directory itself and carries the
+ /// search pattern, so it becomes .
+ /// is reported against each matched child —
+ /// FindFirstFileEx's first result and every FindNextFile — so it is an existence probe on
+ /// that child. Recording it as an enumeration would key a
+ /// on a file path, which can never re-validate
+ /// because the lookup-time directory check always fails, permanently missing the cache. Member-list
+ /// changes are still caught by the directory's own observation.
+ ///
+ ///
+ internal static ObservationType? ClassifyObservation(RequestedAccess requestedAccess, uint error)
+ {
+ if ((requestedAccess & (RequestedAccess.Read | RequestedAccess.Write)) != 0)
+ {
+ return null;
+ }
+
+ if ((requestedAccess & RequestedAccess.Enumerate) != 0)
+ {
+ return ObservationType.DirectoryEnumeration;
+ }
+
+ if ((requestedAccess & (RequestedAccess.Probe | RequestedAccess.EnumerationProbe)) != 0)
+ {
+ return IsKnownAbsentError(error)
+ ? ObservationType.AbsentPathProbe
+ : ObservationType.ExistingProbe;
+ }
+
+ return null;
+ }
+
private sealed class FileAccessesState : IDisposable
{
private readonly object _stateLock = new();
@@ -82,6 +208,10 @@ private sealed class FileAccessesState : IDisposable
private List? _deletedDirectories = new();
+ // Captured probe and enumeration observations, in arrival order. Null when
+ // EnableProbeAndEnumerationFingerprinting is off — AddFileAccess uses null as the signal to short-circuit.
+ private List? _observations;
+
private long _fileAccessCounter;
private bool _isFinished;
@@ -97,6 +227,13 @@ public FileAccessesState(
_pluginSettings = pluginSettings;
_processTable = processTable;
+ // Only allocate the observations list when the feature flag is on. AddFileAccess uses _observations
+ // being null vs non-null as the capture-or-skip signal so flag-off doesn't pay any per-probe cost.
+ if (_pluginSettings.EnableProbeAndEnumerationFingerprinting)
+ {
+ _observations = new List();
+ }
+
string logFilePath = Path.Combine(nodeContext.LogDirectory, "fileAccesses.log");
_logFileStream = File.CreateText(logFilePath);
}
@@ -148,15 +285,6 @@ public void AddFileAccess(FileAccessData fileAccessData)
DesiredAccess desiredAccess = fileAccessData.DesiredAccess;
ReportedFileOperation operation = fileAccessData.Operation;
- // TODO: Remove or uncomment once we figure out whether we want this.
- // Ignore these operations as they're a bit too spammy for what we need
- //if (operation == ReportedFileOperation.FindFirstFileEx
- // || operation == ReportedFileOperation.GetFileAttributes
- // || operation == ReportedFileOperation.GetFileAttributesEx)
- //{
- // return;
- //}
-
uint processId = fileAccessData.ProcessId;
RequestedAccess requestedAccess = fileAccessData.RequestedAccess;
uint error = fileAccessData.Error;
@@ -179,6 +307,10 @@ public void AddFileAccess(FileAccessData fileAccessData)
// Note: This is a hot path, so writing fields one at a time to avoid the overhead of a string.Format with many arguments.
_logFileStream.Write(processId);
_logFileStream.Write(", ");
+ _logFileStream.Write(fileAccessData.Id);
+ _logFileStream.Write(", ");
+ _logFileStream.Write(fileAccessData.CorrelationId);
+ _logFileStream.Write(", ");
UInt32FlagsFormatter.Write(_logFileStream, (uint)desiredAccess);
_logFileStream.Write(", ");
UInt32FlagsFormatter.Write(_logFileStream, (uint)flagsAndAttributes);
@@ -197,6 +329,40 @@ public void AddFileAccess(FileAccessData fileAccessData)
_logFileStream.WriteLine();
+ // Classify probes/enumerations BEFORE the generic `error != 0` short-circuit below — probes
+ // can have not-found errors (ERROR_FILE_NOT_FOUND for AbsentPathProbe) that must not be dropped.
+ // RemoveDirectory is handled by _deletedDirectories and is not an observation.
+ ObservationType? observationType = ClassifyObservation(requestedAccess, error);
+
+ if (observationType.HasValue)
+ {
+ // Skip capture entirely when the feature flag is off (probes are the majority of events).
+ if (_observations != null && operation != ReportedFileOperation.RemoveDirectory)
+ {
+ string? enumerationPattern = null;
+ if (observationType.Value == ObservationType.DirectoryEnumeration)
+ {
+ // Reaching here means the feature is enabled, which is only possible when the
+ // host reports the pattern (see FileAccessDataCapabilities). "*" means the
+ // caller applied no filter, so it is normalized to null to match how an
+ // unreported pattern is represented — otherwise the same enumeration would
+ // fold into two distinct entries depending on how the caller spelled it.
+ enumerationPattern = FileAccessDataCapabilities.GetEnumeratePattern(ref fileAccessData);
+ if (string.IsNullOrEmpty(enumerationPattern) || enumerationPattern == "*")
+ {
+ enumerationPattern = null;
+ }
+ }
+
+ _observations.Add(new ObservedAccess(path, observationType.Value, enumerationPattern));
+ }
+
+ // Bump the counter so probes participate in event ordering. Unused gaps are benign —
+ // only relative order matters (the counter governs RemoveDirectory↔write ordering).
+ _fileAccessCounter++;
+ return;
+ }
+
if (error != 0)
{
// we don't want to process failing file accesses- logging them with the error code
@@ -212,13 +378,6 @@ public void AddFileAccess(FileAccessData fileAccessData)
_deletedDirectories.Add(new RemoveDirectoryOperation(_fileAccessCounter, path));
}
}
- else if (requestedAccess == RequestedAccess.Enumerate
- || requestedAccess == RequestedAccess.EnumerationProbe
- || requestedAccess == RequestedAccess.Probe)
- {
- // Don't add enumerations and probes to fileAccessInfo as they are not needed.
- // We still want to log them for debugging though which is why they're not filtered earlier.
- }
else if (_fileTable != null)
{
if (!_fileTable.TryGetValue(path, out FileAccessInfo? access))
@@ -278,6 +437,7 @@ public FileAccesses FinishProject()
{
Dictionary fileTable;
List deletedDirectories;
+ List? observations;
lock (_stateLock)
{
_isFinished = true;
@@ -285,10 +445,12 @@ public FileAccesses FinishProject()
fileTable = _fileTable!;
deletedDirectories = _deletedDirectories!;
+ observations = _observations;
// Allow memory to be reclaimed
_fileTable = null;
_deletedDirectories = null;
+ _observations = null;
if (_pluginSettings.AllowFileAccessAfterProjectFinishFilePatterns.Count == 0 &&
_pluginSettings.AllowFileAccessAfterProjectFinishProcessPatterns.Count == 0 &&
@@ -298,7 +460,11 @@ public FileAccesses FinishProject()
}
}
- return ProcessFileAccesses(fileTable, deletedDirectories);
+ return ProcessFileAccesses(
+ fileTable,
+ deletedDirectories,
+ observations,
+ _pluginSettings.IgnoredInputPatterns);
}
private Glob? IsAllowFileAccessAfterProjectFinishFilePatterns(string fileName) =>
@@ -312,10 +478,12 @@ public FileAccesses FinishProject()
private static FileAccesses ProcessFileAccesses(
Dictionary fileTable,
- List deletedDirectories)
+ List deletedDirectories,
+ List? observations,
+ IReadOnlyCollection ignoredInputPatterns)
{
var outputs = new HashSet(StringComparer.OrdinalIgnoreCase);
- var inputs = new HashSet(StringComparer.OrdinalIgnoreCase);
+ List allObservations = new();
IEnumerable outputFileInfos = fileTable
.Select(fileInfoKvp => fileInfoKvp.Value)
@@ -346,10 +514,133 @@ private static FileAccesses ProcessFileAccesses(
continue;
}
- inputs.Add(filePath);
+ allObservations.Add(new ObservedAccess(filePath, ObservationType.FileContentRead));
+ }
+
+ // Drop probe observations for paths written by this project — post-write probes reflect
+ // intra-build state, not the pre-build state that drives cache lookup. Keeping them would
+ // cause false misses for probe-then-write patterns (e.g. a target
+ // that probes `obj/A.GeneratedCode.cs`, generates it if missing, then re-probes).
+ //
+ // We use a broader "ever-written" set than `outputs`: outputs filters to existing-file outputs
+ // only, which would leak transient temp files, build-created directories, and orphan-parent
+ // files. Cross-project probes still survive — this is project-local, not graph-wide.
+ //
+ // Ancestor directories of every written file are also filtered: MSBuild commonly probes an
+ // output directory (e.g., `bin\Debug\net9.0\`) before creating it; without this filter,
+ // re-observation would promote the cached AbsentPathProbe to ExistingProbe and miss.
+ //
+ // For DirectoryEnumeration observations that survive the filter, PartitionDirectoryMembers
+ // splits the directory's contents into Members (external) and WrittenMembers (self-outputs).
+ // At lookup, the WrittenMembers list cancels whatever the previous build wrote, so cache hits
+ // remain correct whether outputs are still on disk or not.
+ if (observations != null && observations.Count > 0)
+ {
+ // Single pass over fileTable: collect writtenPaths and build a per-directory leaf-name
+ // index of self-writes so we can partition each surviving DirectoryEnumeration's members
+ // in O(membersInDir) time.
+ List writtenPaths = new();
+ Dictionary> writtenLeafNamesByDir = new(StringComparer.OrdinalIgnoreCase);
+ foreach (KeyValuePair kvp in fileTable)
+ {
+ if (!EverWritten(kvp.Value))
+ {
+ continue;
+ }
+
+ string writtenPath = kvp.Key;
+ writtenPaths.Add(writtenPath);
+
+ string parent = TrimTrailingSeparator(Path.GetDirectoryName(writtenPath) ?? string.Empty);
+ string leaf = Path.GetFileName(writtenPath);
+ if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(leaf))
+ {
+ continue;
+ }
+
+ if (!writtenLeafNamesByDir.TryGetValue(parent, out HashSet? leafSet))
+ {
+ leafSet = new HashSet(StringComparer.OrdinalIgnoreCase);
+ writtenLeafNamesByDir[parent] = leafSet;
+ }
+
+ leafSet.Add(leaf);
+ }
+
+ HashSet everWritten = new(
+ writtenPaths.Select(TrimTrailingSeparator),
+ StringComparer.OrdinalIgnoreCase);
+ HashSet everWrittenOrAncestor = BuildEverWrittenOrAncestorSet(writtenPaths);
+
+ foreach (ObservedAccess obs in observations)
+ {
+ if (ShouldExcludeSelfOutputObservation(obs, everWritten, everWrittenOrAncestor))
+ {
+ continue;
+ }
+
+ if (obs.Type != ObservationType.DirectoryEnumeration)
+ {
+ allObservations.Add(obs);
+ continue;
+ }
+
+ // Enrich the DirectoryEnumeration observation with the partitioned member lists.
+ string dirAbsolute = TrimTrailingSeparator(obs.Path);
+ HashSet selfOutputLeafNames = writtenLeafNamesByDir.TryGetValue(dirAbsolute, out HashSet? leaves)
+ ? leaves
+ : new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ (IReadOnlyList? members, IReadOnlyList? writtenMembers) = PartitionDirectoryMembers(
+ dirAbsolute,
+ obs.EnumerationPattern,
+ selfOutputLeafNames,
+ ignoredInputPatterns);
+ allObservations.Add(obs with { Members = members, WrittenMembers = writtenMembers });
+ }
+ }
+
+ return new FileAccesses(allObservations, outputs);
+ }
+
+ ///
+ /// Enumerates the directory and partitions members into Members (external dependencies) and
+ /// WrittenMembers (this build's outputs). Both lists are leaf names, sorted
+ /// OrdinalIgnoreCase. Returns (null, null) if the directory is missing or inaccessible.
+ ///
+ private static (IReadOnlyList? Members, IReadOnlyList? WrittenMembers) PartitionDirectoryMembers(
+ string absoluteDirectoryPath,
+ string? enumerationPattern,
+ HashSet selfOutputLeafNames,
+ IReadOnlyCollection ignoredInputPatterns)
+ {
+ IReadOnlyList? enumeratedMembers =
+ DirectoryEnumerationReader.EnumerateLeafNames(
+ absoluteDirectoryPath,
+ enumerationPattern,
+ ignoredInputPatterns);
+ if (enumeratedMembers is null)
+ {
+ return (null, null);
+ }
+
+ List members = new();
+ List writtenMembers = new();
+ foreach (string leaf in enumeratedMembers)
+ {
+ if (selfOutputLeafNames.Contains(leaf))
+ {
+ writtenMembers.Add(leaf);
+ }
+ else
+ {
+ members.Add(leaf);
+ }
}
- return new FileAccesses(inputs, outputs);
+ members.Sort(StringComparer.OrdinalIgnoreCase);
+ writtenMembers.Sort(StringComparer.OrdinalIgnoreCase);
+ return (members, writtenMembers);
}
private static bool IsOutput(FileAccessInfo fileInfo)
diff --git a/src/Common/FileAccess/FileAccesses.cs b/src/Common/FileAccess/FileAccesses.cs
index f55e401..c89c5d2 100644
--- a/src/Common/FileAccess/FileAccesses.cs
+++ b/src/Common/FileAccess/FileAccesses.cs
@@ -5,15 +5,6 @@
namespace Microsoft.MSBuildCache.FileAccess;
-internal sealed class FileAccesses
-{
- public FileAccesses(IReadOnlyCollection inputs, IReadOnlyCollection outputs)
- {
- Inputs = inputs;
- Outputs = outputs;
- }
-
- public IReadOnlyCollection Inputs { get; }
-
- public IReadOnlyCollection Outputs { get; }
-}
+internal sealed record FileAccesses(
+ IReadOnlyCollection Observations,
+ IReadOnlyCollection Outputs);
diff --git a/src/Common/FileAccess/ObservedAccess.cs b/src/Common/FileAccess/ObservedAccess.cs
new file mode 100644
index 0000000..7e98618
--- /dev/null
+++ b/src/Common/FileAccess/ObservedAccess.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Generic;
+using Microsoft.MSBuildCache.Fingerprinting;
+
+namespace Microsoft.MSBuildCache.FileAccess;
+
+///
+/// A single typed sandbox observation that contributes (subject to filtering) to the strong
+/// fingerprint as an . Created by as
+/// probe and enumeration events arrive from MSBuild; consumed by 's
+/// GetPathSet.
+///
+public sealed record ObservedAccess(
+ string Path,
+ ObservationType Type,
+ string? EnumerationPattern = null,
+ IReadOnlyList? Members = null,
+ IReadOnlyList? WrittenMembers = null);
diff --git a/src/Common/Fingerprinting/DirectoryEnumerationReader.cs b/src/Common/Fingerprinting/DirectoryEnumerationReader.cs
new file mode 100644
index 0000000..d517d96
--- /dev/null
+++ b/src/Common/Fingerprinting/DirectoryEnumerationReader.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Collections.Generic;
+using System.IO;
+using BuildXL.Native.IO;
+using DotNet.Globbing;
+
+namespace Microsoft.MSBuildCache.Fingerprinting;
+
+///
+/// Enumerates directory member names with the exact Win32 search pattern reported by the sandbox.
+///
+internal static class DirectoryEnumerationReader
+{
+ ///
+ /// Returns the matching, non-ignored leaf names, or null when the directory could not be
+ /// enumerated.
+ ///
+ ///
+ /// A general-purpose glob is not equivalent to Win32 matching: for example, Win32 *.* also
+ /// matches extensionless names. BuildXL's native filesystem layer preserves those semantics,
+ /// including DOS_STAR, DOS_QM, and DOS_DOT patterns, on both target frameworks.
+ ///
+ public static IReadOnlyList? EnumerateLeafNames(
+ string absoluteDirectoryPath,
+ string? enumerationPattern,
+ IReadOnlyCollection? ignoredInputPatterns = null)
+ {
+ var members = new List();
+ string searchPattern = string.IsNullOrEmpty(enumerationPattern) ? "*" : enumerationPattern!;
+
+ EnumerateDirectoryResult result = FileUtilities.EnumerateDirectoryEntries(
+ absoluteDirectoryPath,
+ recursive: false,
+ searchPattern,
+ (_, name, _) =>
+ {
+ string absoluteMemberPath = Path.Combine(absoluteDirectoryPath, name);
+ if (ignoredInputPatterns is null || !MatchesAny(absoluteMemberPath, ignoredInputPatterns))
+ {
+ members.Add(name);
+ }
+ });
+
+ return result.Succeeded ? members : null;
+ }
+
+ private static bool MatchesAny(string path, IReadOnlyCollection patterns)
+ {
+ foreach (Glob pattern in patterns)
+ {
+ if (pattern.IsMatch(path))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Common/Fingerprinting/FingerprintFactory.cs b/src/Common/Fingerprinting/FingerprintFactory.cs
index d37288b..e6574da 100644
--- a/src/Common/Fingerprinting/FingerprintFactory.cs
+++ b/src/Common/Fingerprinting/FingerprintFactory.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft. All rights reserved.
+// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System;
@@ -11,6 +11,7 @@
using BuildXL.Cache.ContentStore.Hashing;
using BuildXL.Cache.ContentStore.Interfaces.Extensions;
using DotNet.Globbing;
+using Microsoft.MSBuildCache.FileAccess;
using Microsoft.MSBuildCache.Hashing;
using NuGet.Versioning;
@@ -35,6 +36,11 @@ public sealed class FingerprintFactory : IFingerprintFactory
private readonly PluginSettings _pluginSettings;
private readonly PathNormalizer _pathNormalizer;
+ // Sentinels computed once per factory instance from the configured content hasher; constant across
+ // instances using the same HashType.
+ internal byte[] AbsentFileSentinel { get; }
+ internal byte[] ZeroHash { get; }
+
public FingerprintFactory(
IContentHasher contentHasher,
IInputHasher inputHasher,
@@ -46,6 +52,11 @@ public FingerprintFactory(
_pluginSettings = pluginSettings;
_pathNormalizer = pathNormalizer;
+ byte[] ComputeWellKnownHash(string value) => contentHasher.GetContentHash(Encoding.UTF8.GetBytes(value)).ToHashByteArray();
+
+ AbsentFileSentinel = ComputeWellKnownHash("Microsoft.MSBuildCache.Fingerprinting.AbsentFile");
+ ZeroHash = ComputeWellKnownHash("Microsoft.MSBuildCache.Fingerprinting.Zero");
+
_pluginSettingsFingerprintEntries = new List()
{
CreateFingerprintEntry($"NodeBuildResultVersion: {NodeBuildResult.CurrentVersion}"),
@@ -70,6 +81,9 @@ void AddSettingToFingerprint(IReadOnlyCollection? patterns, string setting
AddSettingToFingerprint(pluginSettings.AllowFileAccessAfterProjectFinishFilePatterns, nameof(pluginSettings.AllowFileAccessAfterProjectFinishFilePatterns));
AddSettingToFingerprint(pluginSettings.AllowFileAccessAfterProjectFinishProcessPatterns, nameof(pluginSettings.AllowFileAccessAfterProjectFinishProcessPatterns));
AddSettingToFingerprint(pluginSettings.AllowProcessCloseAfterProjectFinishProcessPatterns, nameof(pluginSettings.AllowProcessCloseAfterProjectFinishProcessPatterns));
+
+ _pluginSettingsFingerprintEntries.Add(
+ CreateFingerprintEntry($"{nameof(pluginSettings.EnableProbeAndEnumerationFingerprinting)}: {pluginSettings.EnableProbeAndEnumerationFingerprinting}"));
}
public async Task GetWeakFingerprintAsync(NodeContext nodeContext)
@@ -159,52 +173,191 @@ void AddSettingToFingerprint(IReadOnlyCollection? patterns, string setting
return CreateFingerprint(entries);
});
- public PathSet? GetPathSet(NodeContext nodeContext, IEnumerable observedInputs)
+ public PathSet? GetPathSet(NodeContext nodeContext, IReadOnlyCollection observations)
{
- List pathSetIncludedNormalizedInputs = new();
- List pathSetExcludedNormalizedInputs = new();
-
HashSet predictedInputsSet = new(StringComparer.OrdinalIgnoreCase);
foreach (string input in nodeContext.Inputs)
{
predictedInputsSet.Add(input);
}
- // As an optimization, only include non-predicted inputs. If a predicted input changes, the weak fingerprint
- // will not match and so the associated PathSets will never be used.
- foreach (string observedInput in observedInputs)
+ (List included, List excluded) = FilterObservations(
+ observations,
+ predictedInputsSet,
+ _inputHasher,
+ _pathNormalizer,
+ _pluginSettings.IgnoredInputPatterns);
+
+ // Build the typed entry list. Extracted to a testable static helper.
+ List sortedEntries = FoldPathSetEntries(
+ included,
+ enableProbeAndEnumeration: _pluginSettings.EnableProbeAndEnumerationFingerprinting);
+
+ // To help with debugging, dump the files which were included and excluded from the PathSet.
+ // When probe/enumeration fingerprinting is active, include the type column so the log is diagnosable.
+ File.WriteAllLines(
+ Path.Combine(nodeContext.LogDirectory, "pathSetIncluded.txt"),
+ sortedEntries.Select(e => e.Type == ObservationType.FileContentRead
+ ? e.Path
+ : (e.EnumerationPattern is null
+ ? $"[{e.Type}] {e.Path}"
+ : $"[{e.Type} {e.EnumerationPattern}] {e.Path}")));
+ excluded.Sort(StringComparer.OrdinalIgnoreCase);
+ File.WriteAllLines(Path.Combine(nodeContext.LogDirectory, "pathSetExcluded.txt"), excluded);
+
+ // If the PathSet is effectively empty, return null instead.
+ if (sortedEntries.Count == 0)
+ {
+ return null;
+ }
+
+ return new PathSet(sortedEntries);
+ }
+
+ ///
+ /// Filters and normalizes sandbox observations into records suitable for
+ /// inclusion in a . Drops:
+ ///
+ /// - Predicted inputs (already covered by the weak fingerprint).
+ /// - Observations whose absolute path matches one of .
+ /// - Probe/enumeration observations outside any known root (see ).
+ ///
+ /// For FileContentRead observations, paths the input hasher cannot hash are returned in the
+ /// Excluded list (for debug logging) rather than the included list.
+ ///
+ internal static (List Included, List Excluded) FilterObservations(
+ IReadOnlyCollection observations,
+ HashSet predictedInputs,
+ IInputHasher inputHasher,
+ PathNormalizer pathNormalizer,
+ IReadOnlyCollection ignoredInputPatterns)
+ {
+ List included = new();
+ List excluded = new();
+
+ foreach (ObservedAccess observation in observations)
{
- if (predictedInputsSet.Contains(observedInput))
+ // Predicted inputs are already covered by the weak fingerprint; skip.
+ if (predictedInputs.Contains(observation.Path))
{
continue;
}
- string normalizedInputPath = _pathNormalizer.Normalize(observedInput);
- if (_inputHasher.ContainsPath(observedInput))
+ // Drop observations whose path matches a configured ignore pattern. Applied before normalization
+ // so the pattern matches against the absolute path (the form users author).
+ if (MatchesAny(observation.Path, ignoredInputPatterns))
{
- pathSetIncludedNormalizedInputs.Add(normalizedInputPath);
+ continue;
}
- else
+
+ string normalizedPath = pathNormalizer.Normalize(observation.Path);
+
+ if (observation.Type == ObservationType.FileContentRead)
{
- pathSetExcludedNormalizedInputs.Add(normalizedInputPath);
+ // FCR contributes only if the hasher can hash this path; otherwise it just goes to a debug log.
+ if (inputHasher.ContainsPath(observation.Path))
+ {
+ included.Add(new ObservedPathEntry(normalizedPath, ObservationType.FileContentRead));
+ }
+ else
+ {
+ excluded.Add(normalizedPath);
+ }
+ }
+ else if (PathNormalizer.IsNormalized(normalizedPath))
+ {
+ // Probe/enum contributes only if it's under a known root.
+ included.Add(new ObservedPathEntry(normalizedPath, observation.Type, observation.EnumerationPattern, observation.Members, observation.WrittenMembers));
}
}
- // Sort the collections for consistent ordering
- pathSetIncludedNormalizedInputs.Sort(StringComparer.OrdinalIgnoreCase);
- pathSetExcludedNormalizedInputs.Sort(StringComparer.OrdinalIgnoreCase);
+ return (included, excluded);
+ }
- // To help with debugging, dump the files which were included and excluded from the PathSet.
- File.WriteAllLines(Path.Combine(nodeContext.LogDirectory, "pathSetIncluded.txt"), pathSetIncludedNormalizedInputs);
- File.WriteAllLines(Path.Combine(nodeContext.LogDirectory, "pathSetExcluded.txt"), pathSetExcludedNormalizedInputs);
+ private static bool MatchesAny(string path, IReadOnlyCollection patterns)
+ {
+ if (patterns.Count == 0)
+ {
+ return false;
+ }
- // If the PathSet is effectively empty, return null instead.
- if (pathSetIncludedNormalizedInputs.Count == 0)
+ foreach (Glob pattern in patterns)
{
- return null;
+ if (pattern.IsMatch(path))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Folds observations into a canonical, sorted list of . For multiple
+ /// observations of the same path:
+ ///
+ /// - The highest-precedence type wins (per