Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function get() {
return content.ToArray();
}

private static string ComputeFingerprintSuffix(ResourceAssetCollection resourceCollection)
internal static string ComputeFingerprintSuffix(ResourceAssetCollection resourceCollection)
{
var resources = (IReadOnlyList<ResourceAsset>)resourceCollection;
var incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
Expand All @@ -118,6 +118,12 @@ private static string ComputeFingerprintSuffix(ResourceAssetCollection resourceC
{
var url = resource.Url;
AppendToHash(incrementalHash, buffer, ref rented, url);

var additionalData = GetAdditionalDataForFingerprint(resource);
if (additionalData != null)
{
AppendToHash(incrementalHash, buffer, ref rented, additionalData);
}
}
incrementalHash.GetCurrentHash(result);
// Base64 encoding at most increases size by (4 * byteSize / 3 + 2),
Expand All @@ -128,6 +134,37 @@ private static string ComputeFingerprintSuffix(ResourceAssetCollection resourceC
return fingerprintSpan[..(length + 1)].ToString();
}

private static string? GetAdditionalDataForFingerprint(ResourceAsset resource)
{
// For non-fingerprinted assets (those without a 'label' property), we need to include
// the integrity hash in the fingerprint calculation. This ensures that if the content
// changes between builds, the resource-collection fingerprint also changes.
if (resource.Properties == null)
{
return null;
}

var hasLabel = false;
string? integrity = null;
foreach (var property in resource.Properties)
{
if (property.Name.Equals("label", StringComparison.OrdinalIgnoreCase))
{
hasLabel = true;
// No need to continue if we found a label - we won't use integrity
break;
}
else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase))
{
integrity = property.Value;
// Continue searching in case there's also a label property
}
}

// If there's no label (non-fingerprinted) but there is an integrity value, return it
return !hasLabel ? integrity : null;
}

private static void AppendToHash(IncrementalHash incrementalHash, Span<byte> buffer, ref byte[]? rented, string value)
{
if (Encoding.UTF8.TryGetBytes(value, buffer, out var written))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components.Endpoints;

public class ResourceCollectionUrlEndpointTest
{
[Fact]
public void ComputeFingerprintSuffix_IncludesIntegrityForNonFingerprintedAssets()
Copy link
Member

Choose a reason for hiding this comment

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

Look at the microsoftdocs and use modern C# (collection initializers, target type, etc.) where possible.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated to use collection expressions and target-typed new() throughout the tests for modern C# style. Commit: 9c8b9c1

{
// Arrange - Create a collection with non-fingerprinted assets that have integrity hashes
ResourceAsset[] resources =
[
// Non-fingerprinted asset with integrity (simulates WasmFingerprintAssets=false scenario)
new("/_framework/MyApp.dll",
[
new("integrity", "sha256-ABC123")
]),
new("/_framework/System.dll",
[
new("integrity", "sha256-XYZ789")
])
];
var collection = new ResourceAssetCollection(resources);

// Act
var fingerprint1 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection);

// Arrange - Change the integrity of one asset (simulates content change between builds)
resources =
[
new("/_framework/MyApp.dll",
[
new("integrity", "sha256-CHANGED")
]),
new("/_framework/System.dll",
[
new("integrity", "sha256-XYZ789")
])
];
collection = new ResourceAssetCollection(resources);

// Act
var fingerprint2 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection);

// Assert - Fingerprints should be different because integrity changed
Assert.NotEqual(fingerprint1, fingerprint2);
}

[Fact]
public void ComputeFingerprintSuffix_DoesNotIncludeIntegrityForFingerprintedAssets()
{
// Arrange - Create a collection with fingerprinted assets (have label property)
ResourceAsset[] resources =
[
// Fingerprinted asset with label (simulates WasmFingerprintAssets=true scenario)
new("/_framework/MyApp.ABC123.dll",
[
new("label", "MyApp.dll"),
new("integrity", "sha256-ABC123")
]),
new("/_framework/System.XYZ789.dll",
[
new("label", "System.dll"),
new("integrity", "sha256-XYZ789")
])
];
var collection = new ResourceAssetCollection(resources);

// Act
var fingerprint1 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection);

// Arrange - Change the integrity (but not the URL) of one asset
// For fingerprinted assets, the URL already contains the hash, so we don't need to include integrity
resources =
[
new("/_framework/MyApp.ABC123.dll",
[
new("label", "MyApp.dll"),
new("integrity", "sha256-CHANGED")
]),
new("/_framework/System.XYZ789.dll",
[
new("label", "System.dll"),
new("integrity", "sha256-XYZ789")
])
];
collection = new ResourceAssetCollection(resources);

// Act
var fingerprint2 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection);

// Assert - Fingerprints should be the same because for fingerprinted assets,
// the URL (not integrity) is what matters, and the URL didn't change
Assert.Equal(fingerprint1, fingerprint2);
}

[Fact]
public void ComputeFingerprintSuffix_HandlesMixedAssets()
{
// Arrange - Mix of fingerprinted and non-fingerprinted assets
ResourceAsset[] resources =
[
// Fingerprinted asset
new("/_framework/MyApp.ABC123.dll",
[
new("label", "MyApp.dll"),
new("integrity", "sha256-ABC123")
]),
// Non-fingerprinted asset
new("/_framework/custom.js",
[
new("integrity", "sha256-CUSTOM")
])
];
var collection = new ResourceAssetCollection(resources);

// Act
var fingerprint1 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection);

// Arrange - Change only the non-fingerprinted asset's integrity
resources =
[
// Fingerprinted asset (same as before)
new("/_framework/MyApp.ABC123.dll",
[
new("label", "MyApp.dll"),
new("integrity", "sha256-ABC123")
]),
// Non-fingerprinted asset with changed integrity
new("/_framework/custom.js",
[
new("integrity", "sha256-MODIFIED")
])
];
collection = new ResourceAssetCollection(resources);

// Act
var fingerprint2 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection);

// Assert - Fingerprints should be different because non-fingerprinted asset's integrity changed
Assert.NotEqual(fingerprint1, fingerprint2);
}

[Fact]
public void ComputeFingerprintSuffix_HandlesAssetsWithNoProperties()
{
// Arrange
ResourceAsset[] resources =
[
new("/_framework/file1.dll", null),
new("/_framework/file2.dll", [])
];
var collection = new ResourceAssetCollection(resources);

// Act & Assert - Should not throw
var fingerprint = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection);
Assert.NotNull(fingerprint);
Assert.StartsWith(".", fingerprint);
}
Copy link
Member

Choose a reason for hiding this comment

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

@copilot Add a unit test that simulates the original scenario, which is the integrity of a non-fingerprinted asset changes.

First we compute the fingerprint suffix, then we change the integrity on a non-fingerprinted asset and verify the suffix is different.

Copy link
Member

Choose a reason for hiding this comment

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

This is the only test we need for this. All other behavior will be well covered by existing tests

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added ComputeFingerprintSuffix_ChangesWhenNonFingerprintedAssetIntegrityChanges test that simulates the original scenario - computes fingerprint with initial integrity, changes the integrity on a non-fingerprinted asset (same URL), and verifies the fingerprint is different. Commit: 0a8a76e


[Fact]
public void ComputeFingerprintSuffix_ChangesWhenNonFingerprintedAssetIntegrityChanges()
{
// This test simulates the original bug scenario:
// When WasmFingerprintAssets=false, changing a file (e.g., Counter.razor or wwwroot/styles.css)
// updates its integrity hash but not its URL. The resource-collection fingerprint must change
// to prevent serving stale cached resource-collection.js with outdated integrity values.

// Arrange - Initial state: non-fingerprinted asset with original integrity
var collection1 = new ResourceAssetCollection(
[
new("/_framework/app.styles.css",
[
new("integrity", "sha256-OriginalHash123456")
])
]);

// Act - Compute initial fingerprint
var fingerprintSuffix1 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection1);

// Arrange - Simulate content change: same URL, different integrity (e.g., after modifying styles.css)
var collection2 = new ResourceAssetCollection(
[
new("/_framework/app.styles.css",
[
new("integrity", "sha256-ModifiedHash789012")
])
]);

// Act - Compute fingerprint after content change
var fingerprintSuffix2 = ResourceCollectionUrlEndpoint.ComputeFingerprintSuffix(collection2);

// Assert - Fingerprint must be different to ensure browser fetches updated resource-collection.js
Assert.NotEqual(fingerprintSuffix1, fingerprintSuffix2);
}
}
Loading