namespace XFESystem.Browser; public readonly record struct BrowserResourceReceipt(string Address, string Kind, int Characters, string? RequestedAddress = null); /// One navigation's bounded resource fetches, with the same TLS/mixed-content rules as its document. internal sealed class BrowserResourceLoader(IWebTransport transport, IBrowserResourceSource resources, Uri documentAddress, BrowserLimits limits, bool bundledOnly = false) { private int count; private long characters; public List Receipts { get; } = []; public bool LastResponsePackaged { get; private set; } public WebResponse Get(Uri target, string kind) { BrowserService.ValidateWebAddress(target); if (bundledOnly && target.GetLeftPart(UriPartial.Authority) != PackagedBrowserResources.Origin) throw new InvalidOperationException("Local document subresources must be bundled with XFESystem."); if (++count > limits.MaximumExternalResources) throw new BrowserLimitException("The external resource count limit was reached."); if (documentAddress.Scheme == "https" && target.Scheme != "https") throw new InvalidOperationException("An HTTPS document cannot load an HTTP subresource."); var requestLimits = limits with { MaximumHtmlCharacters = Math.Min(8_388_608, limits.MaximumExternalResourceCharacters) }; WebResponse response; LastResponsePackaged = resources.TryGet(target, out response!); if (!LastResponsePackaged) { if (bundledOnly || target.GetLeftPart(UriPartial.Authority) == PackagedBrowserResources.Origin) throw new FileNotFoundException("The packaged browser resource does not exist: " + target.AbsolutePath); response = transport.Get(target, requestLimits); if (BrowserService.IsReservedAddress(response.Address)) throw new InvalidOperationException("A network subresource cannot impersonate a bundled resource."); } if (bundledOnly && response.Address.GetLeftPart(UriPartial.Authority) != PackagedBrowserResources.Origin) throw new InvalidOperationException("A local document resource cannot redirect outside its bundled origin."); BrowserService.ValidateWebAddress(response.Address); if ((target.Scheme == "https" || documentAddress.Scheme == "https") && response.Address.Scheme != "https") throw new InvalidOperationException("An HTTPS subresource cannot downgrade to HTTP."); if (response.Address.Scheme == "https" && !response.CertificateValidated) throw new InvalidOperationException("Subresource HTTPS certificate validation was not confirmed."); if (response.StatusCode is < 200 or >= 300) throw new IOException("Subresource HTTP status " + response.StatusCode + "."); int maximum = kind == "style" ? limits.MaximumStyleCharacters : limits.MaximumExternalResourceCharacters; if (response.Body.Length > maximum || (characters += response.Body.Length) > limits.MaximumTotalResourceCharacters) throw new BrowserLimitException("The external resources exceed the navigation's character budget."); var mime = response.ContentType.Split(';')[0].Trim().ToLowerInvariant(); if (kind == "script" && mime is not ("text/javascript" or "application/javascript" or "application/ecmascript" or "text/ecmascript")) throw new NotSupportedException("The script response has an unsupported MIME type: " + mime); if (kind == "style" && mime != "text/css") throw new NotSupportedException("The stylesheet response is not text/css."); Receipts.Add(new(response.Address.AbsoluteUri, kind, response.Body.Length, target.AbsoluteUri)); return response; } }