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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Released on Thursday, July 31 2026.
- Updated to use AngleSharp v1
- Updated for Jint v4 (#89, #97) @tomvanenckevort @lahma
- Updated CreatorCache to be thread-safe (#110) @badnickname
- Updated DOM prototypes to be built from a member layout shared by the whole process, which resolves a type once instead of once per document and lets the engine cache warm member reads @lahma
- Added report for uncaught event-loop exceptions through the browsing context
- Added `JsScriptingOptions` to tune the engine via `WithJs` (#75) @lahma
- Added support for Web Workers (#78)
Expand Down
115 changes: 115 additions & 0 deletions src/AngleSharp.Js.Tests/HostContractVerificationTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
namespace AngleSharp.Js.Tests
{
using Jint;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

/// <summary>
/// Turns Jint's host-contract verifiers on for this suite, and proves they are on.
/// </summary>
/// <remarks>
/// <para>
/// The verifiers are the checks that catch a host object answering one of the engine's
/// extension points in a way that contradicts another - the exact hazard this binding lives
/// with, because its proxies answer four of them: TryGetOwnPropertyValue, ProbeOwnProperty,
/// HasIndex, and the semantics derived from not overriding Get. Every one of those is trusted
/// by the engine and never re-checked on the hot path, so a wrong answer is silent: a member
/// disappears from every enumeration, or a read that should have found an attribute resolves
/// on the prototype instead. A verified run is the only thing that turns any of that into a
/// failure.
/// </para>
/// <para>
/// They used to be compiled out of Release, so reaching them meant building Jint from source
/// in Debug. Since 4.15.3 the shipped package reads an AppContext switch instead, which is
/// what makes this a thing every CI run can do against the very package the library ships
/// against.
/// </para>
/// <para>
/// The switch is read once, at the type initialization of the gate behind it, so it has to be
/// set before the first use of any Jint type - a fixture, a one-time setup or a static
/// constructor all run far too late. A module initializer is the only hook early enough by
/// construction: the runtime runs it before any code of this assembly does, and Jint is only
/// ever reached from this assembly's code.
/// </para>
/// </remarks>
[TestFixture]
public class HostContractVerificationTests
{
internal const String SwitchName = "Jint.EnableHostContractVerification";

[ModuleInitializer]
internal static void EnableBeforeAnyJintTypeIsTouched() => AppContext.SetSwitch(SwitchName, true);

/// <summary>
/// That the switch was set early enough, proven from behaviour rather than from the flag:
/// a host whose probe contradicts its own descriptors throws exactly when something is
/// verifying, and is quietly believed when nothing is.
/// </summary>
/// <remarks>
/// It is deliberately not asserted through the gate itself, which is internal to Jint. If
/// this fails, the whole suite has been running unverified and the proxies' hooks are
/// covered by nothing.
/// </remarks>
[Test]
public void TheVerifiersAreRunningForThisSuite()
{
var engine = new Engine();
engine.SetValue("host", new SelfContradictingHost(engine));

var error = Assert.Throws<InvalidOperationException>(() => engine.Evaluate("Object.keys(host).join(',')"),
"The host-contract verifiers must be on, or nothing checks this binding's own hooks.");

StringAssert.Contains("lied", error.Message);
}

/// <summary>
/// Denies through its probe one name its own GetOwnProperty plainly serves. The engine
/// trusts the probe and never re-asks, so unverified the key simply vanishes from every
/// enumeration with nothing to see.
/// </summary>
private sealed class SelfContradictingHost : ObjectInstance
{
public SelfContradictingHost(Engine engine)
: base(engine)
{
}

public override PropertyDescriptor GetOwnProperty(JsValue property)
{
var name = property.ToString();
return name == "honest" || name == "lied"
? new PropertyDescriptor(name, true, true, true)
: PropertyDescriptor.Undefined;
}

// Seen as "protected" from outside the Jint assembly, which is what a host writes.
protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) =>
property.ToString() == "lied" ? OwnPropertyProbe.Missing : base.ProbeOwnProperty(property);

public override List<JsValue> GetOwnPropertyKeys(Types types = Types.String | Types.Symbol) =>
new List<JsValue> { new JsString("honest"), new JsString("lied") };
}
}
}

#if !NET5_0_OR_GREATER
namespace System.Runtime.CompilerServices
{
using System;

/// <summary>
/// The attribute the compiler recognizes by name rather than by identity, so declaring it
/// here is what gives the initializer above a target framework where the BCL carries none.
/// </summary>
[AttributeUsage(AttributeTargets.Method, Inherited = false)]
sealed class ModuleInitializerAttribute : Attribute
{
}
}
#endif
86 changes: 86 additions & 0 deletions src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
namespace AngleSharp.Js.Tests
{
using AngleSharp.Dom;
using AngleSharp.Js.Proxies;
using AngleSharp.Scripting;
using Jint;
using Jint.Native.Object;
using NUnit.Framework;
using System;
using System.Threading.Tasks;

/// <summary>
/// Pins the property access semantics Jint derives for the proxy types.
/// </summary>
/// <remarks>
/// Every proxy here is ordinary, and the engine works that out by itself: a type gets the
/// short read path because it does not override Get, and loses it because it does. Nothing
/// declares that anywhere, so adding a Get override - the obvious way to intercept a read -
/// would silently move every read against that type onto the long path, with no test failing
/// and no behaviour changing. These assertions are the alarm for that.
/// </remarks>
[TestFixture]
public class PropertyAccessSemanticsTests
{
private static Task AssertOrdinaryAsync(String expression, Type expected) =>
AssertOrdinaryAsync(expression, (engine, instance) =>
// Assert what it is as well as how it reads: if the expression stops producing
// the proxy under test the semantics assertion would still pass, and pass for
// the wrong object.
Assert.AreEqual(expected, instance.GetType(), expression + " produced an unexpected proxy type."));

private static async Task AssertOrdinaryAsync(String expression, Action<Engine, ObjectInstance> identify)
{
var context = BrowsingContext.New(Configuration.Default.WithJs());
var document = await context.OpenAsync(m => m.Content(
"<!doctype html><html><body><span id='s'>x</span></body></html>")).ConfigureAwait(false);
var engine = context.GetService<JsScriptingService>().GetOrCreateJint(document);
var value = engine.Evaluate(expression);

Assert.IsInstanceOf<ObjectInstance>(value, expression + " did not evaluate to an object.");

var instance = (ObjectInstance)value;

identify.Invoke(engine, instance);

Assert.AreEqual(PropertyAccessSemantics.Ordinary, engine.Advanced.GetPropertyAccessSemantics(instance),
instance.GetType().Name + " must keep ordinary read semantics; overriding Get forfeits them.");

context.Dispose();
}

[Test]
public Task NodeProxyReadsAsOrdinary() =>
AssertOrdinaryAsync("document.createElement('div')", typeof(DomNodeInstance));

[Test]
public Task CollectionProxyReadsAsOrdinary() =>
AssertOrdinaryAsync("document.getElementsByTagName('span')", typeof(DomCollectionInstance));

/// <summary>
/// The prototype is identified by its representation rather than by its type, because it
/// no longer has one of ours: it is an object over a shared member layout, and that is
/// exactly the property carrying the win - a host subclass used as a prototype is refused
/// as an inline-cache holder by design, an object in the shared layout is not.
/// </summary>
/// <remarks>
/// The member read is what settles the answer: the representation is installed on first
/// touch, and the diagnostic deliberately does not perturb an untouched object into it.
/// </remarks>
[Test]
public Task PrototypeReadsAsOrdinary() =>
AssertOrdinaryAsync(
"(function () { var d = document.createElement('div'); d.tagName; return Object.getPrototypeOf(d); })()",
(engine, instance) =>
Assert.IsTrue(engine.Advanced.HasSharedShape(instance),
"A DOM prototype must be an object over a shared member layout."));

[Test]
public Task ConstructorReadsAsOrdinary() =>
AssertOrdinaryAsync("HTMLDivElement", typeof(DomConstructorInstance));

[Test]
public Task ConstructorFunctionReadsAsOrdinary() =>
AssertOrdinaryAsync("Image", typeof(DomConstructorFunctionInstance));
}
}
Loading
Loading