namespace XFESystem.Browser;
public readonly record struct BrowserResourceReceipt(string Address, string Kind, int Characters, string? RequestedAddress = null);
/// <summary>One navigation's bounded resource fetches, with the same TLS/mixed-content rules as its document.</summary>
internal sealed class BrowserResourceLoader(IWebTransport transport, IBrowserResourceSource resources, Uri documentAddress, BrowserLimits limits, bool bundledOnly = false)
{
private int count;
private long characters;
private readonly Dictionary<string, WebResponse> prefetched = new(StringComparer.Ordinal);
public List<BrowserResourceReceipt> Receipts { get; } = [];
public bool LastResponsePackaged { get; private set; }
public WebResponse Get(Uri target, string kind, IReadOnlyDictionary<string, string>? headers = null)
{
BrowserService.ValidateWebAddress(target);
if (kind == "script" && prefetched.Remove(target.AbsoluteUri, out var cached))
{
LastResponsePackaged = false;
Receipts.Add(new(cached.Address.AbsoluteUri, kind, cached.Body.Length, target.AbsoluteUri));
return cached;
}
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);
if (kind == "fetch" && transport is IWebRequestTransport requestTransport) response = requestTransport.Get(target, requestLimits, headers ?? new Dictionary<string, string>());
else if (headers is { Count: > 0 }) throw new NotSupportedException("The transport cannot send script request headers.");
else 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.");
if (response.BinaryBody is { } bytes && (bytes.Length > limits.MaximumImageBytes || (characters += bytes.Length) > limits.MaximumTotalResourceCharacters))
throw new BrowserLimitException("The image exceeds the navigation's resource budget.");
var mime = response.ContentType.Split(';')[0].Trim().ToLowerInvariant();
bool javaScript = mime is "text/javascript" or "application/javascript" or "application/ecmascript" or "text/ecmascript" or "application/x-javascript";
if (kind == "script" && (response.NoSniff && !javaScript || mime.StartsWith("image/", StringComparison.Ordinal) ||
mime.StartsWith("audio/", StringComparison.Ordinal) || mime.StartsWith("video/", StringComparison.Ordinal) || mime == "text/csv"))
throw new NotSupportedException("The script response has an unsupported MIME type: " + mime + " (" + target.GetLeftPart(UriPartial.Path) + ")");
if (kind == "style" && mime != "text/css") throw new NotSupportedException("The stylesheet response is not text/css.");
if (kind == "image" && !mime.StartsWith("image/", StringComparison.Ordinal)) throw new NotSupportedException("The image response is not an image (" + target.GetLeftPart(UriPartial.Path) + ").");
if (kind == "fetch" && mime is "text/javascript" or "application/javascript" or "application/ecmascript" or "text/ecmascript" or "application/x-javascript")
prefetched[target.AbsoluteUri] = response;
Receipts.Add(new(response.Address.AbsoluteUri, kind, response.Body.Length, target.AbsoluteUri));
return response;
}
}
namespace XFESystem.Browser;
public readonly record struct BrowserResourceReceipt(string Address, string Kind, int Characters, string? RequestedAddress = null);
/// <summary>One navigation's bounded resource fetches, with the same TLS/mixed-content rules as its document.</summary>
internal sealed class BrowserResourceLoader(IWebTransport transport, IBrowserResourceSource resources, Uri documentAddress, BrowserLimits limits, bool bundledOnly = false)
{
private int count;
private long characters;
private readonly Dictionary<string, WebResponse> prefetched = new(StringComparer.Ordinal);
public List<BrowserResourceReceipt> Receipts { get; } = [];
public bool LastResponsePackaged { get; private set; }
public WebResponse Get(Uri target, string kind, IReadOnlyDictionary<string, string>? headers = null)
{
BrowserService.ValidateWebAddress(target);
if (kind == "script" && prefetched.Remove(target.AbsoluteUri, out var cached))
{
LastResponsePackaged = false;
Receipts.Add(new(cached.Address.AbsoluteUri, kind, cached.Body.Length, target.AbsoluteUri));
return cached;
}
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);
if (kind == "fetch" && transport is IWebRequestTransport requestTransport) response = requestTransport.Get(target, requestLimits, headers ?? new Dictionary<string, string>());
else if (headers is { Count: > 0 }) throw new NotSupportedException("The transport cannot send script request headers.");
else 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.");
if (response.BinaryBody is { } bytes && (bytes.Length > limits.MaximumImageBytes || (characters += bytes.Length) > limits.MaximumTotalResourceCharacters))
throw new BrowserLimitException("The image exceeds the navigation's resource budget.");
var mime = response.ContentType.Split(';')[0].Trim().ToLowerInvariant();
bool javaScript = mime is "text/javascript" or "application/javascript" or "application/ecmascript" or "text/ecmascript" or "application/x-javascript";
if (kind == "script" && (response.NoSniff && !javaScript || mime.StartsWith("image/", StringComparison.Ordinal) ||
mime.StartsWith("audio/", StringComparison.Ordinal) || mime.StartsWith("video/", StringComparison.Ordinal) || mime == "text/csv"))
throw new NotSupportedException("The script response has an unsupported MIME type: " + mime + " (" + target.GetLeftPart(UriPartial.Path) + ")");
if (kind == "style" && mime != "text/css") throw new NotSupportedException("The stylesheet response is not text/css.");
if (kind == "image" && !mime.StartsWith("image/", StringComparison.Ordinal)) throw new NotSupportedException("The image response is not an image (" + target.GetLeftPart(UriPartial.Path) + ").");
if (kind == "fetch" && mime is "text/javascript" or "application/javascript" or "application/ecmascript" or "text/ecmascript" or "application/x-javascript")
prefetched[target.AbsoluteUri] = response;
Receipts.Add(new(response.Address.AbsoluteUri, kind, response.Body.Length, target.AbsoluteUri));
return response;
}
}