Skip to content
Open
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
126 changes: 126 additions & 0 deletions apps/pwabuilder-microsoft-store/Common/WebManifestSanitizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace PWABuilder.MicrosoftStore.Common;

/// <summary>
/// Sanitizes a PWA web app manifest so it can be safely converted into a valid Windows AppxManifest by pwa_builder.exe.
/// </summary>
public static class WebManifestSanitizer
{
private const string WildcardLabelPrefix = "*.";

/// <summary>
/// Produces a JSON string for the manifest with any values that would generate an invalid AppxManifest fixed up or removed.
/// </summary>
/// <remarks>
/// Currently this strips wildcard labels from <c>scope_extensions</c> origins. The Windows <c>windows.appUriHandler</c>
/// <c>Host Name</c> element requires a fully-qualified domain name and rejects wildcards such as "*.example.com".
/// When PWABuilder passes the "appurihandler" extension, pwa_builder.exe turns each scope_extensions origin into a
/// <c>uap3:Host</c> entry, so a wildcard origin produces an invalid manifest and pwa_builder.exe fails with error 0x80080204.
/// See https://github.com/pwa-builder/PWABuilder/issues/6104.
/// </remarks>
/// <param name="manifest">The raw web app manifest.</param>
/// <returns>A JSON string of the sanitized manifest.</returns>
public static string Sanitize(JsonDocument manifest)
{
ArgumentNullException.ThrowIfNull(manifest);

var rawJson = manifest.RootElement.GetRawText();
if (JsonNode.Parse(rawJson) is not JsonObject manifestObject)
{
// The manifest isn't a JSON object (unexpected). Leave it untouched.
return rawJson;
}

SanitizeScopeExtensions(manifestObject);
return manifestObject.ToJsonString();
}

/// <summary>
/// Rewrites the <c>scope_extensions</c> array so every origin has a host that's valid for a Windows appUriHandler.
/// Wildcard origins are collapsed to their parent domain, unrepresentable origins are dropped, and duplicates are removed.
/// </summary>
private static void SanitizeScopeExtensions(JsonObject manifestObject)
{
if (!manifestObject.TryGetPropertyValue("scope_extensions", out var scopeExtensionsNode) || scopeExtensionsNode is not JsonArray scopeExtensions)
{
return;
}

var sanitized = new JsonArray();
var seenOrigins = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in scopeExtensions)
{
// Keep anything that isn't a recognizable { "origin": "..." } object untouched.
if (entry is not JsonObject entryObject
|| !entryObject.TryGetPropertyValue("origin", out var originNode)
|| originNode is not JsonValue originValue
|| originValue.GetValueKind() != JsonValueKind.String)
{
sanitized.Add(entry?.DeepClone());
continue;
}

var sanitizedOrigin = SanitizeOrigin(originValue.GetValue<string>());
if (sanitizedOrigin is null || !seenOrigins.Add(sanitizedOrigin))
{
// Drop origins that can't be represented as a valid host, as well as duplicates.
continue;
}

var clone = (JsonObject)entryObject.DeepClone();
clone["origin"] = sanitizedOrigin;
sanitized.Add(clone);
}

manifestObject["scope_extensions"] = sanitized;
}

/// <summary>
/// Sanitizes a single scope_extensions origin so its host is a valid appUriHandler Host Name.
/// </summary>
/// <param name="origin">The origin string, e.g. "https://*.example.com".</param>
/// <returns>The sanitized origin (e.g. "https://example.com"), or <c>null</c> if it can't be represented as a valid host.</returns>
private static string? SanitizeOrigin(string origin)
{
if (string.IsNullOrWhiteSpace(origin))
{
return null;
}

var trimmed = origin.Trim();

// No wildcard means the origin is already usable as-is.
if (!trimmed.Contains('*'))
{
return trimmed;
}

// Split "scheme://host[:port][/...]" into scheme and authority so we only touch the host.
var schemeSeparatorIndex = trimmed.IndexOf("://", StringComparison.Ordinal);
var scheme = schemeSeparatorIndex >= 0 ? trimmed[..(schemeSeparatorIndex + 3)] : string.Empty;
var authority = schemeSeparatorIndex >= 0 ? trimmed[(schemeSeparatorIndex + 3)..] : trimmed;

// Separate the host from any port or trailing path/query/fragment.
var hostEndIndex = authority.IndexOfAny(new[] { ':', '/', '?', '#' });
var host = hostEndIndex >= 0 ? authority[..hostEndIndex] : authority;
var hostSuffix = hostEndIndex >= 0 ? authority[hostEndIndex..] : string.Empty;

// Collapse a wildcard subdomain to its parent domain, e.g. "*.example.com" => "example.com".
while (host.StartsWith(WildcardLabelPrefix, StringComparison.Ordinal))
{
host = host[WildcardLabelPrefix.Length..];
}

// A leftover wildcard (e.g. "sub.*.example.com" or a bare "*") can't be a valid Host Name, so drop the origin.
if (host.Length == 0 || host.Contains('*'))
{
return null;
}

return scheme + host + hostSuffix;
}
}
41 changes: 39 additions & 2 deletions apps/pwabuilder-microsoft-store/Services/PwaBuilderWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ private ProcessException CreatePwaBuilderCliError(
WebAppManifestContext webManifest)
{
var pwabuilderCLIArgs = CreateCommandLineArgs(options, appImages, webManifest, actionFiles, outputDirectory);
var formattedMessage = string.Join(Environment.NewLine + Environment.NewLine, message, $"Output directory: {outputDirectory}", $"Standard output: {standardOutput}", $"Standard error: {standardErrorOutput}", $"CLI args: {pwabuilderCLIArgs}");
// Read whatever AppxManifest pwa_builder.exe generated (if any). This is invaluable for diagnosing
// "invalid manifest" failures (error 0x80080204), where the generated AppxManifest violates the Appx schema.
var appxManifest = TryReadGeneratedAppxManifest(outputDirectory);
var formattedMessage = string.Join(Environment.NewLine + Environment.NewLine, message, $"Output directory: {outputDirectory}", $"Standard output: {standardOutput}", $"Standard error: {standardErrorOutput}", $"CLI args: {pwabuilderCLIArgs}", $"Generated AppxManifest: {appxManifest ?? "(no AppxManifest was generated)"}");
var toolFailedError = new ProcessException(formattedMessage, innerException, standardOutput, standardErrorOutput);
toolFailedError.Data.Add("StandardOutput", standardOutput);
toolFailedError.Data.Add("StandardError", standardErrorOutput);
Expand All @@ -124,10 +127,44 @@ private ProcessException CreatePwaBuilderCliError(
toolFailedError.Data.Add("url", options.Url);
toolFailedError.Data.Add("appImagesCount", appImages.ImagePaths.Count);
toolFailedError.Data.Add("appImages", appImages.ImagePaths);
logger.LogError(toolFailedError, toolFailedError.Message);
toolFailedError.Data.Add("appxManifest", appxManifest);
toolFailedError.Data.Add("webManifest", options.Manifest?.RootElement.GetRawText());
// Log the AppxManifest as a structured property so it's captured in App Insights customDimensions.
logger.LogError(toolFailedError, "pwa_builder.exe failed to create the Windows app package. {ErrorMessage} Generated AppxManifest: {AppxManifest}", toolFailedError.Message, appxManifest ?? "(no AppxManifest was generated)");
return toolFailedError;
}

/// <summary>
/// Attempts to read the AppxManifest (.xml) that pwa_builder.exe generated in the output directory.
/// pwa_builder.exe writes the AppxManifest before it packs and closes the .msix, so the file is
/// typically present even when packaging fails with an invalid-manifest error.
/// </summary>
/// <param name="outputDirectory">The output directory pwa_builder.exe was told to write to.</param>
/// <returns>The AppxManifest XML contents, or null if none could be read.</returns>
private string? TryReadGeneratedAppxManifest(string outputDirectory)
{
try
{
if (!Directory.Exists(outputDirectory))
{
return null;
}

// The AppxManifest is written as an .xml file in the output directory, or in an architecture
// subdirectory when building widget packages, so search recursively.
var appxManifestFile = Directory
.EnumerateFiles(outputDirectory, "*.xml", SearchOption.AllDirectories)
.FirstOrDefault();
return appxManifestFile is not null ? File.ReadAllText(appxManifestFile) : null;
}
catch (Exception readError)
{
// Never let diagnostics reading mask the original pwa_builder.exe failure.
logger.LogWarning(readError, "Unable to read the generated AppxManifest from {outputDirectory} while handling a pwa_builder.exe failure.", outputDirectory);
return null;
}
}

private string GetRequiredFile(string extension, string directory, string cliOutput, string cliErrorOutput)
{
var file = Directory.EnumerateFiles(directory, "*" + extension).FirstOrDefault();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ private async Task GenerateManifestFile(WindowsAppPackageOptions options)

try
{
var jsonString = JsonSerializer.Serialize(options.Manifest);
// Sanitize the manifest before handing it to pwa_builder.exe. For example, wildcard scope_extensions
// origins must be collapsed to a valid host, otherwise pwa_builder.exe emits an invalid AppxManifest
// (error 0x80080204). See https://github.com/pwa-builder/PWABuilder/issues/6104.
var jsonString = options.Manifest is JsonDocument manifest
? WebManifestSanitizer.Sanitize(manifest)
: JsonSerializer.Serialize(options.Manifest);
using (StreamWriter writer = new StreamWriter(manifestFilePath))
{
await writer.WriteAsync(jsonString);
Expand Down
Loading