diff --git a/CHANGELOG.md b/CHANGELOG.md index c1836ab..47bb348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ Released on Friday, November 6 2026. - Added classes with implicit conversions standing for a WebIDL union, e.g. of a string and a sequence of strings, which fixes such conversions failing; an implicit conversion from such a class is what a script receives for it - Added the same-origin policy: script gets `null` for a node of another origin, and for its window a `CrossOriginWindow` with only `window`, `self`, `frames`, `parent`, `top`, `closed`, `length`, `location` (only to be set, and only for frames inside the own document), `postMessage`, `focus`, `blur` and `close` - Added `performance.mark`, `measure`, `getEntries`, `getEntriesByName`, `getEntriesByType`, `clearMarks`, `clearMeasures`, along with `PerformanceEntry`, `PerformanceMark` and `PerformanceMeasure` +- Fixed modules being read from the local disk; every module is loaded through the configured requesters (#153) @michaelthyregod +- Fixed modules imported by a relative address being requested from the wrong address (e.g. `./a.js` of `/dir/page.html` from `/dir/page.html/a.js`, and the imports of a module of another origin from the own origin); they are resolved against what imports them (#153) @michaelthyregod # 1.1.4 diff --git a/src/AngleSharp.Js.Tests/EcmaTests.cs b/src/AngleSharp.Js.Tests/EcmaTests.cs index b63af1a..39ad976 100644 --- a/src/AngleSharp.Js.Tests/EcmaTests.cs +++ b/src/AngleSharp.Js.Tests/EcmaTests.cs @@ -6,6 +6,7 @@ namespace AngleSharp.Js.Tests using NUnit.Framework; using System; using System.Collections.Generic; + using System.IO; using System.Threading.Tasks; [TestFixture] @@ -493,5 +494,75 @@ public async Task MicrotasksRunAfterSynchronousWorkInOrder() var document = await context.OpenAsync(r => r.Content(html)).WhenStable().ConfigureAwait(false); Assert.AreEqual("sync,m1,m2", document.GetElementById("test")?.TextContent); } + + [TestCase("direct")] + [TestCase("importmap")] + [TestCase("dynamic")] + [TestCase("script")] + [TestCase("bytes")] + public async Task ModuleIsNotReadFromTheLocalDisk(String way) + { + var json = Path.GetTempFileName(); + var script = Path.GetTempFileName(); + + try + { + File.WriteAllText(json, "{ \"secret\": \"from disk\" }"); + File.WriteAllText(script, "document.getElementById('test').textContent = 'from disk';"); + var jsonUrl = new Uri(json).AbsoluteUri; + var scriptUrl = new Uri(script).AbsoluteUri; + var show = "document.getElementById('test').textContent = "; + var page = way switch + { + "direct" => $"", + "importmap" => $"", + "dynamic" => $"", + "script" => $"", + _ => $"", + }; + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new MockHttpClientRequester(new Dictionary())) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var html = "
pending
" + page; + var document = await BrowsingContext.New(config) + .OpenAsync(r => r.Content(html).Address("https://example.com/")) + .WhenStable() + .ConfigureAwait(false); + Assert.AreEqual("pending", document.GetElementById("test").TextContent); + } + finally + { + File.Delete(json); + File.Delete(script); + } + } + + [TestCase("", "/dir/a.js")] + [TestCase("", "/a.js")] + [TestCase("", "/a.js")] + [TestCase("", "/dir/a.js")] + [TestCase("", "/other/a.js")] + [TestCase("", "/dir/a.js")] + public async Task RelativeModuleIsLoadedFromTheAddressOfWhatImportsIt(String page, String path) + { + // Only the expected address serves the module; any other one answers with nothing. + var config = Configuration.Default + .WithJs() + .WithEventLoop() + .With(new MockHttpClientRequester(new Dictionary + { + { "/dir/mod.js", "import x from './a.js'; show(x);" }, + { path, "export default 'ok';" }, + })) + .WithDefaultLoader(new LoaderOptions { IsResourceLoadingEnabled = true }); + var html = "
pending
" + page; + var document = await BrowsingContext.New(config) + .OpenAsync(r => r.Content(html).Address("https://example.com/dir/page.html")) + .WhenStable() + .ConfigureAwait(false); + Assert.AreEqual("ok", document.GetElementById("test").TextContent); + } } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index 96ec062..b63f10d 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -49,7 +49,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I { _importMap = new JsImportMap(); var engineOptions = new Options(); - engineOptions.EnableModules(new JsModuleLoader(this, window.Document, false)); + engineOptions.EnableModules(new JsModuleLoader(this, window.Document)); // The handler uses the caches initialized below. The creator must return the // engine before DOM objects are wrapped or page scripts are evaluated. engineOptions.SetWrapObjectHandler(WrapObject); diff --git a/src/AngleSharp.Js/JsModuleLoader.cs b/src/AngleSharp.Js/JsModuleLoader.cs index ec547c4..a200cbf 100644 --- a/src/AngleSharp.Js/JsModuleLoader.cs +++ b/src/AngleSharp.Js/JsModuleLoader.cs @@ -9,23 +9,28 @@ namespace AngleSharp.Js using System; using System.Linq; - internal class JsModuleLoader : DefaultModuleLoader + internal class JsModuleLoader : ModuleLoader { private readonly EngineInstance _instance; private readonly IResourceLoader _resourceLoader; private readonly IElement _scriptElement; - private readonly string _documentUrl; + private readonly IDocument _document; - public JsModuleLoader(EngineInstance instance, IDocument document, bool restrictToBasePath = true) : base (document.Url, restrictToBasePath) + public JsModuleLoader(EngineInstance instance, IDocument document) { _instance = instance; _resourceLoader = document.Context.GetService(); _scriptElement = document.CreateElement(TagNames.Script); - _documentUrl = document.Url; + _document = document; } + // The key of a resolved module is its address, and its Uri stays null: Jint gives a module + // loaded with a Uri only the path of it as location, so the modules it imports would lose + // the origin. With the key as location, they are resolved against the full address. public override ResolvedSpecifier Resolve(string referencingModuleLocation, ModuleRequest moduleRequest) { + var specifier = moduleRequest.Specifier; + if (referencingModuleLocation != null && _instance.ImportMap.Scopes.Count > 0) { foreach (var scopePath in _instance.ImportMap.Scopes.Keys.OrderByDescending(k => k.Length)) @@ -34,76 +39,93 @@ public override ResolvedSpecifier Resolve(string referencingModuleLocation, Modu { var scopeImports = _instance.ImportMap.Scopes[scopePath]; - if (scopeImports.TryGetValue(moduleRequest.Specifier, out var scopeModuleUrl)) + if (scopeImports.TryGetValue(specifier, out var scopeModuleUrl)) { - if (!scopeModuleUrl.IsAbsoluteUri) - { - scopeModuleUrl = new Uri(new Uri(_documentUrl), scopeModuleUrl); - } - - return new ResolvedSpecifier( - moduleRequest, - moduleRequest.Specifier, - scopeModuleUrl, - SpecifierType.RelativeOrAbsolute); + return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), scopeModuleUrl.OriginalString)); } } } } - if (_instance.ImportMap.Imports.TryGetValue(moduleRequest.Specifier, out var moduleUrl)) + if (_instance.ImportMap.Imports.TryGetValue(specifier, out var moduleUrl)) { - if (!moduleUrl.IsAbsoluteUri) - { - moduleUrl = new Uri(new Uri(_documentUrl), moduleUrl); - } - - return new ResolvedSpecifier( - moduleRequest, - moduleRequest.Specifier, - moduleUrl, - SpecifierType.RelativeOrAbsolute); + return Resolved(moduleRequest, new Url(GetDocumentBaseUrl(), moduleUrl.OriginalString)); } - // Before passing to the base default module loader, make sure any relative paths are prepended with a dot - // as Jint will otherwise resolve them as absolute file paths when running on Linux - if (moduleRequest.Specifier.StartsWith("/")) + if (specifier.StartsWith("/") || specifier.StartsWith("./") || specifier.StartsWith("../")) { - moduleRequest = new ModuleRequest($".{moduleRequest.Specifier}", moduleRequest.Attributes); + return Resolved(moduleRequest, new Url(GetBaseUrl(referencingModuleLocation), specifier)); } - if (referencingModuleLocation?.StartsWith("/") == true) + var url = new Url(specifier); + + if (!url.IsInvalid && url.IsAbsolute) { - referencingModuleLocation = $".{referencingModuleLocation}"; + return Resolved(moduleRequest, url); } - - return base.Resolve(referencingModuleLocation, moduleRequest); + + // A bare name, which no address belongs to. The module scripts of the document are + // added to the engine under such a name, so it must stay what it is to find them. + return new ResolvedSpecifier(moduleRequest, specifier, null, SpecifierType.Bare); } + // Every module comes through AngleSharp's resource loader, whatever its scheme, so the + // requesters the host registered decide what a page can reach. protected override string LoadModuleContents(Engine engine, ResolvedSpecifier resolved) { - if (resolved.Uri?.Scheme.IsOneOf(ProtocolNames.Http, ProtocolNames.Https) == true) + return FetchModule(GetModuleUrl(resolved)); + } + + protected override Byte[] LoadModuleContentsAsBytes(Engine engine, ResolvedSpecifier resolved) + { + return FetchModuleBytes(GetModuleUrl(resolved)); + } + + private static ResolvedSpecifier Resolved(ModuleRequest moduleRequest, Url url) + { + return new ResolvedSpecifier(moduleRequest, url.Href, null, SpecifierType.RelativeOrAbsolute); + } + + // A module resolves against the address of what imports it, which is a module or a script + // that was loaded; one written into the document resolves against the base URL of it. + private Url GetBaseUrl(string referencingModuleLocation) + { + if (referencingModuleLocation != null) { - return FetchModule(resolved.Uri); + var url = new Url(referencingModuleLocation); + + if (!url.IsInvalid && url.IsAbsolute) + { + return url; + } } - else + + return GetDocumentBaseUrl(); + } + + private Url GetDocumentBaseUrl() + { + return _document.BaseUrl ?? new Url(_document.Url); + } + + private static Url GetModuleUrl(ResolvedSpecifier resolved) + { + if (resolved.Type != SpecifierType.RelativeOrAbsolute) { - return base.LoadModuleContents(engine, resolved); + throw new NotSupportedException($"The module '{resolved.ModuleRequest.Specifier}' could not be resolved to a URL."); } + + return new Url(resolved.Key); } - private string FetchModule(Uri moduleUrl) + private string FetchModule(Url moduleUrl) { if (_resourceLoader == null) { return string.Empty; } - var importUrl = Url.Convert(moduleUrl); - - var request = new ResourceRequest(_scriptElement, importUrl); - - var response = _resourceLoader.FetchAsync(request).Task.Result; + var response = Fetch(moduleUrl); string content; @@ -114,5 +136,28 @@ private string FetchModule(Uri moduleUrl) return content; } + + private Byte[] FetchModuleBytes(Url moduleUrl) + { + if (_resourceLoader == null) + { + return Array.Empty(); + } + + var response = Fetch(moduleUrl); + + using (var content = new MemoryStream()) + { + response.Content.CopyTo(content); + return content.ToArray(); + } + } + + private IResponse Fetch(Url moduleUrl) + { + var request = new ResourceRequest(_scriptElement, moduleUrl); + var response = _resourceLoader.FetchAsync(request).Task.Result; + return response ?? throw new InvalidOperationException($"No requester could load the module '{moduleUrl.Href}'."); + } } } diff --git a/src/AngleSharp.Js/JsScriptingService.cs b/src/AngleSharp.Js/JsScriptingService.cs index 0501203..f7c89cd 100644 --- a/src/AngleSharp.Js/JsScriptingService.cs +++ b/src/AngleSharp.Js/JsScriptingService.cs @@ -100,10 +100,12 @@ public async Task EvaluateScriptAsync(IResponse response, ScriptOptions options, var content = await reader.ReadToEndAsync().ConfigureAwait(false); var type = options.Element?.Type; // A module is imported by this, so it has to tell it apart from other modules: - // one written into the document would have the address of the document. + // one written into the document would have the address of the document. One that + // was loaded has its address, which is what the modules it imports resolve against. var isModule = type.Isi("module"); - var sourceUrl = isModule ? options.Element?.Source : response.Address?.Href ?? options.Element?.Source; - var position = isModule || !String.IsNullOrEmpty(options.Element?.Source) ? default : FindInDocument(options.Document, content); + var isInline = String.IsNullOrEmpty(options.Element?.Source); + var sourceUrl = isModule && isInline ? null : response.Address?.Href ?? options.Element?.Source; + var position = isModule || !isInline ? default : FindInDocument(options.Document, content); await options.EventLoop.EnqueueAsync(_ => { var document = options.Document ?? throw new ArgumentNullException(nameof(options));