Skip to content
Merged
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
99 changes: 74 additions & 25 deletions src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -589,10 +589,19 @@ public DateTimeOffset LastWriteTime
}

public static StaticWebAsset FromTaskItem(ITaskItem item, bool validate = false)
=> FromTaskItem(item, TaskEnvironment.Fallback, validate);

/// <summary>
/// Builds a <see cref="StaticWebAsset"/> from an MSBuild item, absolutizing path-bearing
/// metadata (ContentRoot, RelatedAsset) against the supplied <paramref name="env"/>'s
/// project directory rather than the process current directory. Required for multithreaded
/// MSBuild execution.
/// </summary>
public static StaticWebAsset FromTaskItem(ITaskItem item, TaskEnvironment env, bool validate = false)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here is the entry point if you're looking at the PR

{
var result = FromTaskItemCore(item);

result.Normalize();
result.Normalize(env);
if (validate)
{
result.Validate();
Expand Down Expand Up @@ -803,12 +812,15 @@ private bool HasKind(string assetKind) =>
AssetKinds.IsKind(AssetKind, assetKind);

public static StaticWebAsset FromV1TaskItem(ITaskItem item)
=> FromV1TaskItem(item, TaskEnvironment.Fallback);

public static StaticWebAsset FromV1TaskItem(ITaskItem item, TaskEnvironment env)
{
var result = FromTaskItemCore(item);
result.ApplyDefaults();
result.ApplyDefaults(env);
result.OriginalItemSpec = string.IsNullOrEmpty(result.OriginalItemSpec) ? item.GetMetadata("FullPath") : result.OriginalItemSpec;

result.Normalize();
result.Normalize(env);
result.Validate();

return result;
Expand All @@ -822,7 +834,9 @@ private static StaticWebAsset FromTaskItemCore(ITaskItem item)
};
}

public void ApplyDefaults()
public void ApplyDefaults() => ApplyDefaults(TaskEnvironment.Fallback);

public void ApplyDefaults(TaskEnvironment env)
{
CopyToOutputDirectory = string.IsNullOrEmpty(CopyToOutputDirectory) ? AssetCopyOptions.Never : CopyToOutputDirectory;
CopyToPublishDirectory = string.IsNullOrEmpty(CopyToPublishDirectory) ? AssetCopyOptions.PreserveNewest : CopyToPublishDirectory;
Expand All @@ -831,7 +845,7 @@ public void ApplyDefaults()
AssetRole = string.IsNullOrEmpty(AssetRole) ? AssetRoles.Primary : AssetRole;
if (string.IsNullOrEmpty(Fingerprint) || string.IsNullOrEmpty(Integrity) || FileLength == -1 || LastWriteTime == DateTimeOffset.MinValue)
{
var file = ResolveFile(Identity, OriginalItemSpec);
var file = ResolveFile(Identity, OriginalItemSpec, env);
(Fingerprint, Integrity) = string.IsNullOrEmpty(Fingerprint) || string.IsNullOrEmpty(Integrity) ?
ComputeFingerprintAndIntegrityIfNeeded(file) : (Fingerprint, Integrity);
FileLength = FileLength == -1 ? file.Length : FileLength;
Expand Down Expand Up @@ -861,8 +875,11 @@ internal static (string fingerprint, string integrity) ComputeFingerprintAndInte
}

internal static string ComputeIntegrity(string identity, string originalItemSpec)
=> ComputeIntegrity(identity, originalItemSpec, TaskEnvironment.Fallback);

internal static string ComputeIntegrity(string identity, string originalItemSpec, TaskEnvironment env)
{
var fileInfo = ResolveFile(identity, originalItemSpec);
var fileInfo = ResolveFile(identity, originalItemSpec, env);
return ComputeIntegrity(fileInfo);
}

Expand Down Expand Up @@ -1064,20 +1081,24 @@ internal static StaticWebAsset FromProperties(
internal bool HasSourceId(string source) =>
HasSourceId(SourceId, source);

public void Normalize()
public void Normalize() => Normalize(TaskEnvironment.Fallback);

public void Normalize(TaskEnvironment env)
{
ContentRoot = !string.IsNullOrEmpty(ContentRoot) ? NormalizeContentRootPath(ContentRoot) : ContentRoot;
ContentRoot = !string.IsNullOrEmpty(ContentRoot) ? NormalizeContentRootPath(ContentRoot, env) : ContentRoot;
BasePath = Normalize(BasePath);
RelativePath = Normalize(RelativePath, allowEmpyPath: true);
RelatedAsset = !string.IsNullOrEmpty(RelatedAsset) ? Path.GetFullPath(RelatedAsset) : RelatedAsset;
RelatedAsset = !string.IsNullOrEmpty(RelatedAsset) ? Path.GetFullPath(env.GetAbsolutePath(RelatedAsset)) : RelatedAsset;
}

// Normalizes the given path to a content root path in the way we expect it:
// * Converts the path to absolute with Path.GetFullPath(path) which takes care of normalizing
// the directory separators to use Path.DirectorySeparator
// * Appends a trailing directory separator at the end.
public static string NormalizeContentRootPath(string path)
=> Path.GetFullPath(path) +

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is the thing the PR is fundamentally trying to fix

public static string NormalizeContentRootPath(string path) => NormalizeContentRootPath(path, TaskEnvironment.Fallback);

public static string NormalizeContentRootPath(string path, TaskEnvironment env)
=> Path.GetFullPath(string.IsNullOrEmpty(path) ? path : env.GetAbsolutePath(path)) +
// We need to do .ToString because there is no EndsWith overload for chars in .NET Framework
(path.EndsWith(Path.DirectorySeparatorChar.ToString()), path.EndsWith(Path.AltDirectorySeparatorChar.ToString())) switch
{
Expand Down Expand Up @@ -1134,8 +1155,10 @@ public bool ShouldCopyToOutputDirectory()
public bool ShouldCopyToPublishDirectory()
=> !string.Equals(CopyToPublishDirectory, AssetCopyOptions.Never, StringComparison.Ordinal);

public bool HasContentRoot(string path) =>
string.Equals(ContentRoot, NormalizeContentRootPath(path), StringComparison.Ordinal);
public bool HasContentRoot(string path) => HasContentRoot(path, TaskEnvironment.Fallback);

public bool HasContentRoot(string path, TaskEnvironment env) =>
string.Equals(ContentRoot, NormalizeContentRootPath(path, env), StringComparison.Ordinal);

/// <summary>
/// Materializes a framework asset by copying it to the consuming project's intermediate directory
Expand All @@ -1149,6 +1172,15 @@ public static (StaticWebAsset Asset, string OldIdentity, string OldBasePath) Mat
string projectPackageId,
string projectBasePath,
TaskLoggingHelper log)
=> MaterializeFrameworkAsset(asset, intermediateOutputPath, projectPackageId, projectBasePath, log, TaskEnvironment.Fallback);

public static (StaticWebAsset Asset, string OldIdentity, string OldBasePath) MaterializeFrameworkAsset(
StaticWebAsset asset,
string intermediateOutputPath,
string projectPackageId,
string projectBasePath,
TaskLoggingHelper log,
TaskEnvironment env)
{
var originalSourceId = asset.SourceId;
var oldBasePath = asset.BasePath;
Expand All @@ -1158,12 +1190,18 @@ public static (StaticWebAsset Asset, string OldIdentity, string OldBasePath) Mat
var fxDir = Path.Combine(intermediateOutputPath, "fx", originalSourceId);
var fileSystemRelativePath = asset.ComputePathWithoutTokens(relativePath);
var destPath = Path.Combine(fxDir, Normalize(fileSystemRelativePath));
destPath = Path.GetFullPath(destPath);

destPath = Path.GetFullPath(env.GetAbsolutePath(destPath));
if (string.IsNullOrEmpty(asset.Identity))
{
log.LogError("Source file '{0}' does not exist for framework asset materialization.", asset.Identity);
return (null, null, null);
}

var sourceFile = asset.Identity;
var sourceFile = env.GetAbsolutePath(asset.Identity);
if (!File.Exists(sourceFile))
{
log.LogError("Source file '{0}' does not exist for framework asset materialization.", sourceFile);
log.LogError("Source file '{0}' does not exist for framework asset materialization.", sourceFile.OriginalValue);
return (null, null, null);
}

Expand All @@ -1173,22 +1211,22 @@ public static (StaticWebAsset Asset, string OldIdentity, string OldBasePath) Mat
if (!File.Exists(destPath) || File.GetLastWriteTimeUtc(sourceFile) > File.GetLastWriteTimeUtc(destPath))
{
File.Copy(sourceFile, destPath, overwrite: true);
log.LogMessage(MessageImportance.Low, "Materialized framework asset '{0}' to '{1}'.", sourceFile, destPath);
log.LogMessage(MessageImportance.Low, "Materialized framework asset '{0}' to '{1}'.", sourceFile.OriginalValue, destPath);
}
else
{
log.LogMessage(MessageImportance.Low, "Framework asset '{0}' already up to date at '{1}'.", sourceFile, destPath);
log.LogMessage(MessageImportance.Low, "Framework asset '{0}' already up to date at '{1}'.", sourceFile.OriginalValue, destPath);
}

asset.Identity = destPath;
asset.OriginalItemSpec = destPath;
asset.ContentRoot = NormalizeContentRootPath(fxDir);
asset.ContentRoot = NormalizeContentRootPath(fxDir, env);
asset.SourceType = SourceTypes.Discovered;
asset.SourceId = projectPackageId;
asset.BasePath = projectBasePath;
asset.AssetMode = AssetModes.CurrentProject;
asset.AssetGroups = "";
asset.Normalize();
asset.Normalize(env);

return (asset, oldIdentity, oldBasePath);
}
Expand Down Expand Up @@ -1597,16 +1635,21 @@ internal string EmbedTokens(string relativePath)
return pattern.RawPattern.ToString();
}

internal FileInfo ResolveFile() => ResolveFile(Identity, OriginalItemSpec);
internal FileInfo ResolveFile() => ResolveFile(Identity, OriginalItemSpec, TaskEnvironment.Fallback);

internal FileInfo ResolveFile(TaskEnvironment env) => ResolveFile(Identity, OriginalItemSpec, env);

internal static FileInfo ResolveFile(string identity, string originalItemSpec)
=> ResolveFile(identity, originalItemSpec, TaskEnvironment.Fallback);

internal static FileInfo ResolveFile(string identity, string originalItemSpec, TaskEnvironment env)
{
var fileInfo = new FileInfo(identity);
var fileInfo = new FileInfo(string.IsNullOrEmpty(identity) ? identity: env.GetAbsolutePath(identity));
if (fileInfo.Exists)
{
return fileInfo;
}
fileInfo = new FileInfo(originalItemSpec);
fileInfo = new FileInfo(string.IsNullOrEmpty(originalItemSpec) ? originalItemSpec : env.GetAbsolutePath(originalItemSpec));
if (fileInfo.Exists)
{
return fileInfo;
Expand All @@ -1616,23 +1659,29 @@ internal static FileInfo ResolveFile(string identity, string originalItemSpec)
}

internal static Dictionary<string, StaticWebAsset> ToAssetDictionary(ITaskItem[] candidateAssets, bool validate = false)
=> ToAssetDictionary(candidateAssets, TaskEnvironment.Fallback, validate);

internal static Dictionary<string, StaticWebAsset> ToAssetDictionary(ITaskItem[] candidateAssets, TaskEnvironment env, bool validate = false)
{
var dictionary = new Dictionary<string, StaticWebAsset>(candidateAssets.Length);
for (var i = 0; i < candidateAssets.Length; i++)
{
var candidateAsset = FromTaskItem(candidateAssets[i], validate);
var candidateAsset = FromTaskItem(candidateAssets[i], env, validate);
dictionary.Add(candidateAsset.Identity, candidateAsset);
}

return dictionary;
}

internal static StaticWebAsset[] FromTaskItemGroup(ITaskItem[] candidateAssets, bool validate = false)
=> FromTaskItemGroup(candidateAssets, TaskEnvironment.Fallback, validate);

internal static StaticWebAsset[] FromTaskItemGroup(ITaskItem[] candidateAssets, TaskEnvironment env, bool validate = false)
{
var result = new StaticWebAsset[candidateAssets.Length];
for (var i = 0; i != result.Length; i++)
{
var candidateAsset = FromTaskItem(candidateAssets[i], validate);
var candidateAsset = FromTaskItem(candidateAssets[i], env, validate);
result[i] = candidateAsset;
}
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,116 @@ public void AssetGroups_PreservedInManifest()
manifest.Assets.Values.Single().AssetGroups.Should().Be("BootstrapVersion=V5");
}

[Fact]
public void RelatedAsset_Unmapped_ProducesError()
{
// Symmetric to Endpoints_UnmappedAssetFile_ProducesError: exercises the
// GeneratePackageAssetsManifestFile.cs error branch for RelatedAsset that
// can't be remapped to a package-relative path (i.e., points outside the
// packaged asset set). Without this test the RelatedAsset error branch is
// unexercised by automated tests.
var primaryFile = CreateTempFile("wwwroot", "css", "site.css", "body{}");
var relatedFile = CreateTempFile("wwwroot", "css", "site.css.gz", "gz");
var contentRoot = Path.Combine(_tempDir, "wwwroot") + Path.DirectorySeparatorChar;

var primary = CreateAsset(primaryFile, contentRoot, "css/site.css", "abc");

var related = CreateAsset(relatedFile, contentRoot, "css/site.css.gz", "def");
related.AssetRole = "Alternative";
related.AssetTraitName = "Content-Encoding";
related.AssetTraitValue = "gzip";
// RelatedAsset points to a file that is NOT in the StaticWebAssets input set,
// so it has no entry in identityToPackagePath and cannot be remapped.
related.RelatedAsset = Path.Combine(_tempDir, "nonexistent", "primary.css");

var task = CreateManifestTask(
new[] { primary.ToTaskItem(), related.ToTaskItem() });
var result = task.Execute();

result.Should().BeFalse();
_errorMessages.Should().ContainSingle(m =>
m.Contains("could not be mapped to a package-relative path") &&
m.Contains("RelatedAsset"));
File.Exists(task.TargetManifestPath).Should().BeFalse(
"the manifest must not be written when a referential integrity error is detected");
}

[Fact]
public void RoundTrip_GenerateThenRead_RelatedAssetResolvesToConsumerAbsolutePath()
{
// End-to-end cross-boundary test. Proves the contract between
// GeneratePackageAssetsManifestFile (producer) and ReadPackageAssetsManifest
// (consumer): an absolute build-time RelatedAsset is remapped to a
// package-relative form on the producer side, then re-resolved to an
// absolute path under the consumer's packageRoot on the consumer side.
// Two distinct directory trees stand in for producer and consumer machines.
var primaryFile = CreateTempFile("source", "wwwroot", "css", "site.css", "body{}");
var relatedFile = CreateTempFile("source", "wwwroot", "css", "site.css.gz", "gz");
var contentRoot = Path.Combine(_tempDir, "source", "wwwroot") + Path.DirectorySeparatorChar;

var primary = CreateAsset(primaryFile, contentRoot, "css/site.css", "abc");
var related = CreateAsset(relatedFile, contentRoot, "css/site.css.gz", "def");
related.AssetRole = "Alternative";
related.AssetTraitName = "Content-Encoding";
related.AssetTraitValue = "gzip";
related.RelatedAsset = primaryFile;

// Producer side: write the manifest at the layout ReadPackageAssetsManifest expects:
// packageRoot/build/<PackageId>.PackageAssets.json
var packageRoot = Path.Combine(_tempDir, "packages", "MyLib");
var buildDir = Path.Combine(packageRoot, "build");
Directory.CreateDirectory(buildDir);
var manifestPath = Path.Combine(buildDir, "MyLib.PackageAssets.json");

var generateTask = new GeneratePackageAssetsManifestFile
{
BuildEngine = _buildEngine.Object,
StaticWebAssets = new[] { primary.ToTaskItem(), related.ToTaskItem() },
StaticWebAssetEndpoints = Array.Empty<ITaskItem>(),
TargetManifestPath = manifestPath,
};
generateTask.Execute().Should().BeTrue();
File.Exists(manifestPath).Should().BeTrue();

// Consumer side: feed the producer's manifest into ReadPackageAssetsManifest
// pretending we're on a different machine (different packageRoot than the
// producer's contentRoot). The whole point of producer-side package-relative
// remap is that the consumer can re-anchor without knowing the producer's CWD.
var consumerContentRoot = Path.Combine(packageRoot, "staticwebassets") + Path.DirectorySeparatorChar;
var manifestItem = new TaskItem(manifestPath, new Dictionary<string, string>
{
["SourceId"] = "MyLib",
["ContentRoot"] = consumerContentRoot,
["PackageRoot"] = packageRoot,
});

var readTask = new ReadPackageAssetsManifest
{
BuildEngine = _buildEngine.Object,
PackageManifests = new[] { manifestItem },
StaticWebAssetGroups = Array.Empty<ITaskItem>(),
IntermediateOutputPath = Path.Combine(_tempDir, "obj"),
ProjectPackageId = "ConsumerApp",
ProjectBasePath = "_content/consumerapp",
};
readTask.Execute().Should().BeTrue();
readTask.Assets.Should().HaveCount(2);

var emittedRelated = readTask.Assets.Single(a => a.GetMetadata("AssetRole") == "Alternative");
var emittedPrimary = readTask.Assets.Single(a => a.GetMetadata("AssetRole") == "Primary");

// The producer's contentRoot is _tempDir/source/wwwroot — the consumer must
// not see any leakage of it. RelatedAsset on the consumer side must be the
// primary's Identity (absolute path under the consumer's packageRoot).
emittedRelated.GetMetadata("RelatedAsset").Should().Be(emittedPrimary.ItemSpec,
"consumer's RelatedAsset must equal primary's Identity after package-root re-resolution");
emittedRelated.GetMetadata("RelatedAsset").Should().StartWith(packageRoot,
"RelatedAsset must be re-anchored to the consumer's packageRoot, not the producer's contentRoot");
emittedRelated.GetMetadata("RelatedAsset").Should().NotContain(
Path.Combine(_tempDir, "source"),
"no producer-side build-time path may leak through the manifest to the consumer");
}

[Fact]
public void Endpoints_UnmappedAssetFile_ProducesError()
{
Expand Down
Loading
Loading