diff --git a/Jint.Tests.PublicInterface/ClrProxyHandlerTests.cs b/Jint.Tests.PublicInterface/ClrProxyHandlerTests.cs new file mode 100644 index 000000000..b19c9f511 --- /dev/null +++ b/Jint.Tests.PublicInterface/ClrProxyHandlerTests.cs @@ -0,0 +1,691 @@ +#nullable enable + +using Jint.Native; +using Jint.Native.Function; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; +using Jint.Runtime.Interop; + +namespace Jint.Tests.PublicInterface; + +public class ClrProxyHandlerTests +{ + private sealed class DelegatingProxyHandler : ProxyHandler + { + public Func? OnGet; + public Func? OnSet; + public Func? OnHas; + public Func? OnDeleteProperty; + public Func? OnGetOwnPropertyDescriptor; + public Func? OnDefineProperty; + public Func?>? OnOwnKeys; + public Func? OnApply; + public Func? OnConstruct; + public Func? OnGetPrototypeOf; + public Func? OnSetPrototypeOf; + public Func? OnIsExtensible; + public Func? OnPreventExtensions; + + public override JsValue? Get(ObjectInstance target, JsValue property, JsValue receiver) => OnGet?.Invoke(target, property, receiver); + public override bool? Set(ObjectInstance target, JsValue property, JsValue value, JsValue receiver) => OnSet?.Invoke(target, property, value, receiver); + public override bool? Has(ObjectInstance target, JsValue property) => OnHas?.Invoke(target, property); + public override bool? DeleteProperty(ObjectInstance target, JsValue property) => OnDeleteProperty?.Invoke(target, property); + public override PropertyDescriptor? GetOwnPropertyDescriptor(ObjectInstance target, JsValue property) => OnGetOwnPropertyDescriptor?.Invoke(target, property); + public override bool? DefineProperty(ObjectInstance target, JsValue property, PropertyDescriptor descriptor) => OnDefineProperty?.Invoke(target, property, descriptor); + public override IReadOnlyList? OwnKeys(ObjectInstance target) => OnOwnKeys?.Invoke(target); + public override JsValue? Apply(ObjectInstance target, JsValue thisObject, JsValue[] arguments) => OnApply?.Invoke(target, thisObject, arguments); + public override ObjectInstance? Construct(ObjectInstance target, JsValue[] arguments, JsValue newTarget) => OnConstruct?.Invoke(target, arguments, newTarget); + public override JsValue? GetPrototypeOf(ObjectInstance target) => OnGetPrototypeOf?.Invoke(target); + public override bool? SetPrototypeOf(ObjectInstance target, JsValue prototype) => OnSetPrototypeOf?.Invoke(target, prototype); + public override bool? IsExtensible(ObjectInstance target) => OnIsExtensible?.Invoke(target); + public override bool? PreventExtensions(ObjectInstance target) => OnPreventExtensions?.Invoke(target); + } + + public class Calculator + { + public int Offset { get; set; } + + public int Add(int a, int b) => Offset + a + b; + } + + public class Person + { + public string Name { get; set; } = ""; + } + + private static JsObject CreateTarget(Engine engine, params (string Key, JsValue Value)[] properties) + { + var target = new JsObject(engine); + foreach (var (key, value) in properties) + { + target.Set(key, value); + } + return target; + } + + [Fact] + public void GetTrapInterceptsPropertyRead() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("x", 1), ("y", 2)); + var handler = new DelegatingProxyHandler + { + OnGet = (_, property, _) => property.ToString() == "x" ? JsNumber.Create(42) : null + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal(42, engine.Evaluate("p.x").AsNumber()); + // non-trapped key forwards to the target + Assert.Equal(2, engine.Evaluate("p.y").AsNumber()); + } + + [Fact] + public void SetTrapInterceptsPropertyWrite() + { + var engine = new Engine(); + var target = CreateTarget(engine); + var writes = new List(); + var handler = new DelegatingProxyHandler + { + OnSet = (_, property, value, _) => + { + writes.Add($"{property}={value}"); + return true; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + engine.Evaluate("p.x = 42;"); + + Assert.Equal(new[] { "x=42" }, writes); + // the trap claimed the write, the target was not touched + Assert.True(target.Get("x").IsUndefined()); + } + + [Fact] + public void SetTrapReturningFalseThrowsInStrictMode() + { + var engine = new Engine(); + var handler = new DelegatingProxyHandler + { + OnSet = (_, _, _, _) => false + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(CreateTarget(engine), handler)); + + Assert.Throws(() => engine.Evaluate("'use strict'; p.x = 1;")); + } + + [Fact] + public void HasTrapInterceptsInOperator() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("real", 1)); + var handler = new DelegatingProxyHandler + { + OnHas = (_, property) => property.ToString() == "phantom" ? true : null + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.True(engine.Evaluate("'phantom' in p").AsBoolean()); + // forwarded to the target + Assert.True(engine.Evaluate("'real' in p").AsBoolean()); + Assert.False(engine.Evaluate("'missing' in p").AsBoolean()); + } + + [Fact] + public void DeletePropertyTrapInterceptsDelete() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("x", 1)); + var deleted = new List(); + var handler = new DelegatingProxyHandler + { + OnDeleteProperty = (_, property) => + { + deleted.Add(property.ToString()); + return true; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.True(engine.Evaluate("delete p.x").AsBoolean()); + Assert.Equal(new[] { "x" }, deleted); + // the trap claimed the delete without touching the target + Assert.Equal(1, target.Get("x").AsNumber()); + } + + [Fact] + public void GetOwnPropertyDescriptorTrapReportsPhantomAndHiddenProperties() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("hidden", 1), ("visible", 2)); + var handler = new DelegatingProxyHandler + { + OnGetOwnPropertyDescriptor = (_, property) => property.ToString() switch + { + "phantom" => new PropertyDescriptor(JsNumber.Create(7), writable: true, enumerable: true, configurable: true), + "hidden" => PropertyDescriptor.Undefined, // report "no such property" + _ => null // forward + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal(7, engine.Evaluate("Object.getOwnPropertyDescriptor(p, 'phantom').value").AsNumber()); + Assert.True(engine.Evaluate("Object.getOwnPropertyDescriptor(p, 'hidden') === undefined").AsBoolean()); + Assert.Equal(2, engine.Evaluate("Object.getOwnPropertyDescriptor(p, 'visible').value").AsNumber()); + } + + [Fact] + public void DefinePropertyTrapInterceptsDefinition() + { + var engine = new Engine(); + var target = CreateTarget(engine); + var defined = new List(); + var handler = new DelegatingProxyHandler + { + OnDefineProperty = (_, property, descriptor) => + { + defined.Add($"{property}={descriptor.Value}"); + return true; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + engine.Evaluate("Object.defineProperty(p, 'y', { value: 42, configurable: true });"); + + Assert.Equal(new[] { "y=42" }, defined); + Assert.True(target.Get("y").IsUndefined()); + } + + [Fact] + public void OwnKeysTrapFiltersKeys() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("a", 1), ("b", 2), ("c", 3)); + var handler = new DelegatingProxyHandler + { + OnOwnKeys = _ => new JsValue[] { "a", "b" } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal("a,b", engine.Evaluate("Object.getOwnPropertyNames(p).join()").AsString()); + } + + [Fact] + public void ApplyTrapInterceptsCall() + { + var engine = new Engine(); + var target = engine.Evaluate("(function (a, b) { return a + b; })").AsObject(); + JsValue[]? capturedArguments = null; + var handler = new DelegatingProxyHandler + { + OnApply = (_, _, arguments) => + { + // the trap receives a private copy it may hold on to + capturedArguments = arguments; + return JsNumber.Create(100); + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal(100, engine.Evaluate("p(2, 3)").AsNumber()); + Assert.NotNull(capturedArguments); + Assert.Equal(2, capturedArguments.Length); + Assert.Equal(2, capturedArguments[0].AsNumber()); + Assert.Equal(3, capturedArguments[1].AsNumber()); + } + + [Fact] + public void ApplyTrapForwardsWhenNotImplemented() + { + var engine = new Engine(); + var target = engine.Evaluate("(function (a, b) { return a + b; })").AsObject(); + var handler = new DelegatingProxyHandler(); + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal(5, engine.Evaluate("p(2, 3)").AsNumber()); + } + + [Fact] + public void ConstructTrapInterceptsNew() + { + var engine = new Engine(); + var target = engine.Evaluate("(function (a, b) { this.sum = a + b; })").AsObject(); + var handler = new DelegatingProxyHandler + { + OnConstruct = (_, arguments, _) => + { + var result = new JsObject(engine); + result.Set("marker", JsNumber.Create(arguments[0].AsNumber() * arguments[1].AsNumber())); + return result; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal(6, engine.Evaluate("new p(2, 3).marker").AsNumber()); + // trap not consulted for the forwarding check + var forwardingHandler = new DelegatingProxyHandler(); + engine.SetValue("pf", engine.Advanced.CreateProxy(target, forwardingHandler)); + Assert.Equal(5, engine.Evaluate("new pf(2, 3).sum").AsNumber()); + } + + [Fact] + public void GetPrototypeOfTrapCanReportNullPrototype() + { + var engine = new Engine(); + var handler = new DelegatingProxyHandler + { + OnGetPrototypeOf = _ => JsValue.Null + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(CreateTarget(engine), handler)); + + Assert.True(engine.Evaluate("Object.getPrototypeOf(p) === null").AsBoolean()); + } + + [Fact] + public void SetPrototypeOfTrapInterceptsPrototypeChange() + { + var engine = new Engine(); + var target = CreateTarget(engine); + var invocations = 0; + var handler = new DelegatingProxyHandler + { + OnSetPrototypeOf = (_, _) => + { + invocations++; + return true; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + engine.Evaluate("Object.setPrototypeOf(p, { a: 1 });"); + + Assert.Equal(1, invocations); + // the trap claimed success without changing the target's prototype + Assert.True(engine.Evaluate("Object.getPrototypeOf(p) === Object.prototype").AsBoolean()); + } + + [Fact] + public void IsExtensibleTrapIsConsulted() + { + var engine = new Engine(); + var invocations = 0; + var handler = new DelegatingProxyHandler + { + OnIsExtensible = _ => + { + invocations++; + return true; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(CreateTarget(engine), handler)); + + Assert.True(engine.Evaluate("Object.isExtensible(p)").AsBoolean()); + Assert.Equal(1, invocations); + } + + [Fact] + public void PreventExtensionsTrapIsConsulted() + { + var engine = new Engine(); + var target = CreateTarget(engine); + var handler = new DelegatingProxyHandler + { + OnPreventExtensions = t => + { + // honor the invariant: the target must actually become non-extensible + t.PreventExtensions(); + return true; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + engine.Evaluate("Object.preventExtensions(p);"); + + Assert.False(engine.Evaluate("Object.isExtensible(p)").AsBoolean()); + } + + [Fact] + public void GetAndSetTrapsWorkAgainstObjectWrapperTarget() + { + var engine = new Engine(); + var person = new Person { Name = "Jane" }; + var blockedWrites = new List(); + var handler = new DelegatingProxyHandler + { + OnGet = (_, property, _) => property.ToString() == "custom" ? JsNumber.Create(42) : null, + OnSet = (_, property, value, _) => + { + if (property.ToString() == "blocked") + { + blockedWrites.Add(value.ToString()); + return true; + } + return null; + } + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(ObjectWrapper.Create(engine, person), handler)); + + Assert.Equal(42, engine.Evaluate("p.custom").AsNumber()); + Assert.Equal("Jane", engine.Evaluate("p.Name").AsString()); + + engine.Evaluate("p.blocked = 'nope';"); + Assert.Equal(new[] { "nope" }, blockedWrites); + + // untrapped write forwards to the CLR object + engine.Evaluate("p.Name = 'John';"); + Assert.Equal("John", person.Name); + } + + [Fact] + public void ApplyTrapWorksAgainstClrFunctionTarget() + { + var engine = new Engine(); + var target = new ClrFunction(engine, "add", (_, arguments) => JsNumber.Create(arguments[0].AsNumber() + arguments[1].AsNumber())); + var handler = new DelegatingProxyHandler + { + OnApply = (_, _, arguments) => JsNumber.Create(arguments[0].AsNumber() * arguments[1].AsNumber()) + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal(6, engine.Evaluate("p(2, 3)").AsNumber()); + } + + [Fact] + public void HandlerWithOnlyGetLetsWritesReachTarget() + { + var engine = new Engine(); + var target = CreateTarget(engine); + var handler = new DelegatingProxyHandler + { + OnGet = (_, _, _) => null + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + engine.Evaluate("p.x = 1;"); + + Assert.Equal(1, target.Get("x").AsNumber()); + } + + [Fact] + public void TrapReturningUndefinedIsARealResultNotForward() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("x", 5)); + var handler = new DelegatingProxyHandler + { + OnGet = (_, property, _) => property.ToString() == "x" ? JsValue.Undefined : null + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.True(engine.Evaluate("p.x === undefined").AsBoolean()); + } + + [Fact] + public void ConditionalForwardingLetsTargetValuesThrough() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("normal", 1), ("special", 2)); + var handler = new DelegatingProxyHandler + { + OnGet = (_, property, _) => property.ToString() == "special" ? JsNumber.Create(99) : null + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + Assert.Equal(1, engine.Evaluate("p.normal").AsNumber()); + Assert.Equal(99, engine.Evaluate("p.special").AsNumber()); + } + + [Fact] + public void GetTrapLyingAboutFrozenPropertyThrowsTypeError() + { + var engine = new Engine(); + engine.Evaluate("var frozen = Object.freeze({ x: 1 });"); + var target = engine.Evaluate("frozen").AsObject(); + var handler = new DelegatingProxyHandler + { + OnGet = (_, _, _) => JsNumber.Create(42) + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + var ex = Assert.Throws(() => engine.Evaluate("p.x")); + Assert.Contains("'get' on proxy", ex.Message); + } + + [Fact] + public void SetTrapLyingAboutFrozenPropertyThrowsTypeError() + { + var engine = new Engine(); + engine.Evaluate("var frozen = Object.freeze({ x: 1 });"); + var target = engine.Evaluate("frozen").AsObject(); + var handler = new DelegatingProxyHandler + { + OnSet = (_, _, _, _) => true + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + var ex = Assert.Throws(() => engine.Evaluate("p.x = 2;")); + Assert.Contains("'set' on proxy", ex.Message); + } + + [Fact] + public void OwnKeysTrapOmittingNonConfigurableKeyThrowsTypeError() + { + var engine = new Engine(); + engine.Evaluate("var frozen = Object.freeze({ a: 1 });"); + var target = engine.Evaluate("frozen").AsObject(); + var handler = new DelegatingProxyHandler + { + OnOwnKeys = _ => [] + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(target, handler)); + + var ex = Assert.Throws(() => engine.Evaluate("Object.keys(p)")); + Assert.Contains("'ownKeys' on proxy", ex.Message); + } + + private sealed class MethodLoggingHandler : ProxyHandler + { + private readonly Dictionary _wrappers = new(StringComparer.Ordinal); + + public List Calls { get; } = []; + + public override JsValue? Get(ObjectInstance target, JsValue property, JsValue receiver) + { + var value = target.Get(property); + if (value is Function) + { + var name = property.ToString(); + if (!_wrappers.TryGetValue(name, out var wrapper)) + { + // proxy.method(x) fires the get trap and then a plain call on the result; + // to intercept the call, return a wrapping function - memoized so that + // proxy.method === proxy.method still holds + var engine = target.Engine; + wrapper = new ClrFunction(engine, name, (thisObject, arguments) => + { + Calls.Add(name); + return engine.Invoke(value, thisObject, arguments); + }); + _wrappers[name] = wrapper; + } + + return wrapper; + } + + return null; // forward plain values to the target + } + } + + [Fact] + public void MethodCallsCanBeInterceptedByWrappingFunctionsInGetTrap() + { + var engine = new Engine(); + var calculator = new Calculator { Offset = 10 }; + var handler = new MethodLoggingHandler(); + + engine.SetValue("p", engine.Advanced.CreateProxy(ObjectWrapper.Create(engine, calculator), handler)); + + // thisObject passthrough: the bound CLR method sees the original Calculator instance + Assert.Equal(15, engine.Evaluate("p.Add(2, 3)").AsNumber()); + Assert.Equal(new[] { "Add" }, handler.Calls); + + // memoization preserves function identity + Assert.True(engine.Evaluate("p.Add === p.Add").AsBoolean()); + Assert.Equal(new[] { "Add" }, handler.Calls); + + // plain (non-function) members still forward + Assert.Equal(10, engine.Evaluate("p.Offset").AsNumber()); + } + + [Fact] + public void RevocableProxyThrowsAfterRevoke() + { + var engine = new Engine(); + var target = CreateTarget(engine, ("x", 1)); + var revocable = engine.Advanced.CreateRevocableProxy(target, new DelegatingProxyHandler()); + + engine.SetValue("p", revocable.Proxy); + Assert.Equal(1, engine.Evaluate("p.x").AsNumber()); + + revocable.Revoke(); + + var ex = Assert.Throws(() => engine.Evaluate("p.x")); + Assert.Contains("revoked", ex.Message); + + // idempotent + revocable.Revoke(); + + ex = Assert.Throws(() => engine.Evaluate("'x' in p")); + Assert.Contains("revoked", ex.Message); + } + + [Fact] + public void RevokedCallableProxyKeepsTypeofFunction() + { + var engine = new Engine(); + var target = engine.Evaluate("(function () {})").AsObject(); + var revocable = engine.Advanced.CreateRevocableProxy(target, new DelegatingProxyHandler()); + + engine.SetValue("p", revocable.Proxy); + revocable.Revoke(); + + Assert.Equal("function", engine.Evaluate("typeof p").AsString()); + } + + [Fact] + public void CanComposeWithWrapObjectHandler() + { + var handler = new DelegatingProxyHandler + { + OnGet = (_, property, _) => property.ToString() == "intercepted" ? JsNumber.Create(1) : null + }; + + var engine = new Engine(options => + { + options.SetWrapObjectHandler((e, obj, type) => obj is Calculator + ? e.Advanced.CreateProxy(ObjectWrapper.Create(e, obj), handler) + : ObjectWrapper.Create(e, obj, type)); + }); + + var calculator = new Calculator(); + Calculator? received = null; + engine.SetValue("calc", calculator); + engine.SetValue("accept", new Action(c => received = c)); + + // script sees trapped members + Assert.Equal(1, engine.Evaluate("calc.intercepted").AsNumber()); + // untrapped members forward to the wrapped CLR object + Assert.Equal(5, engine.Evaluate("calc.Add(2, 3)").AsNumber()); + + // passing the proxied wrapper back into a CLR method parameter unwraps to the original object + engine.Evaluate("accept(calc);"); + Assert.Same(calculator, received); + } + + [Fact] + public void ReflectGetPassesReceiverToTrap() + { + var engine = new Engine(); + JsValue? capturedReceiver = null; + var proxy = engine.Advanced.CreateProxy(CreateTarget(engine), new DelegatingProxyHandler + { + OnGet = (_, _, receiver) => + { + capturedReceiver = receiver; + return JsNumber.Create(1); + } + }); + + engine.SetValue("p", proxy); + + engine.Evaluate("var r = { marker: true }; Reflect.get(p, 'x', r);"); + Assert.Same(engine.Evaluate("r"), capturedReceiver); + + // default receiver is the proxy itself + engine.Evaluate("p.x"); + Assert.Same(proxy, capturedReceiver); + } + + [Fact] + public void TrapExceptionsBubbleToClrByDefault() + { + var engine = new Engine(); + var handler = new DelegatingProxyHandler + { + OnGet = (_, _, _) => throw new InvalidOperationException("boom") + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(CreateTarget(engine), handler)); + + var ex = Assert.Throws(() => engine.Evaluate("p.x")); + Assert.Equal("boom", ex.Message); + } + + [Fact] + public void TrapExceptionsAreCatchableInScriptWithCatchClrExceptions() + { + var engine = new Engine(options => options.CatchClrExceptions()); + var handler = new DelegatingProxyHandler + { + OnGet = (_, _, _) => throw new InvalidOperationException("boom") + }; + + engine.SetValue("p", engine.Advanced.CreateProxy(CreateTarget(engine), handler)); + + var result = engine.Evaluate("(function () { try { p.x; return 'no-throw'; } catch (e) { return 'caught: ' + e.message; } })()").AsString(); + Assert.Equal("caught: boom", result); + } + + [Fact] + public void FactoriesValidateArguments() + { + var engine = new Engine(); + var target = CreateTarget(engine); + var handler = new DelegatingProxyHandler(); + + Assert.Throws(() => engine.Advanced.CreateProxy(null!, handler)); + Assert.Throws(() => engine.Advanced.CreateProxy(target, null!)); + Assert.Throws(() => engine.Advanced.CreateRevocableProxy(null!, handler)); + Assert.Throws(() => engine.Advanced.CreateRevocableProxy(target, null!)); + } +} diff --git a/Jint/Engine.Advanced.cs b/Jint/Engine.Advanced.cs index 15e1f2134..726de94ad 100644 --- a/Jint/Engine.Advanced.cs +++ b/Jint/Engine.Advanced.cs @@ -1,5 +1,8 @@ using Jint.Native; +using Jint.Native.Object; using Jint.Native.Promise; +using Jint.Runtime; +using Jint.Runtime.Interop; namespace Jint; @@ -64,6 +67,61 @@ public ManualPromise RegisterPromise() return _engine.RegisterPromise(); } + /// + /// Creates an ECMAScript Proxy exotic object whose traps are implemented in .NET by + /// . This is the CLR-side equivalent of new Proxy(target, handlerObject) + /// in script, which remains the way to create proxies with JavaScript handler objects. + /// + /// + /// A trap method returning CLR forwards the operation to + /// , exactly like an absent trap on a JavaScript handler object. + /// All ECMAScript proxy invariants are enforced on trap results. Note that proxy.method(x) + /// fires the trap (returning the method) followed by a plain call + /// on the result — the trap only fires when the proxy itself is + /// invoked, so intercepting method calls means returning a wrapping function from + /// . + /// + /// The proxy target object. + /// The .NET trap implementation. + /// The proxy object, ready to be passed into script. + public ObjectInstance CreateProxy(ObjectInstance target, ProxyHandler handler) + { + if (target is null) + { + Throw.ArgumentNullException(nameof(target)); + } + + if (handler is null) + { + Throw.ArgumentNullException(nameof(handler)); + } + + return new JsProxy(_engine, target, handler); + } + + /// + /// Creates a revocable ECMAScript Proxy exotic object whose traps are implemented in .NET by + /// , mirroring JavaScript's Proxy.revocable(). See + /// for trap forwarding and invariant semantics. + /// + /// The proxy target object. + /// The .NET trap implementation. + /// The proxy paired with its revoke operation. + public RevocableProxy CreateRevocableProxy(ObjectInstance target, ProxyHandler handler) + { + if (target is null) + { + Throw.ArgumentNullException(nameof(target)); + } + + if (handler is null) + { + Throw.ArgumentNullException(nameof(handler)); + } + + return new RevocableProxy(new JsProxy(_engine, target, handler)); + } + /// /// Event raised when a promise is rejected without a handler (operation = Reject), /// or when a handler is added to a previously unhandled rejected promise (operation = Handle). diff --git a/Jint/Native/Function/Function.cs b/Jint/Native/Function/Function.cs index 29586194c..8818f1f61 100644 --- a/Jint/Native/Function/Function.cs +++ b/Jint/Native/Function/Function.cs @@ -270,7 +270,7 @@ internal Realm GetFunctionRealm(JsValue obj) if (obj is JsProxy proxyInstance) { - if (proxyInstance._handler is null) + if (proxyInstance.IsRevoked) { Throw.TypeErrorNoEngine(); } diff --git a/Jint/Native/JsProxy.cs b/Jint/Native/JsProxy.cs index 3c61dd1b5..15266cc8f 100644 --- a/Jint/Native/JsProxy.cs +++ b/Jint/Native/JsProxy.cs @@ -1,7 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; using Jint.Native.Function; using Jint.Native.Object; using Jint.Runtime; using Jint.Runtime.Descriptors; +using Jint.Runtime.Interop; namespace Jint.Native; @@ -9,6 +12,7 @@ internal sealed class JsProxy : ObjectInstance, IConstructor, ICallable { internal ObjectInstance _target; internal ObjectInstance? _handler; + private ProxyHandler? _clrHandler; // https://tc39.es/ecma262/#sec-proxycreate // A proxy has [[Construct]] iff its target is a constructor at creation time; @@ -17,6 +21,10 @@ internal sealed class JsProxy : ObjectInstance, IConstructor, ICallable // remain correct after revocation nulls the target. private readonly bool _isConstructor; + // mirrors ClrFunction: with the default (bubbling) exception handler, CLR trap + // exceptions propagate untouched; otherwise they are routed through the handler + private readonly bool _bubbleExceptions; + private static readonly JsString TrapApply = new JsString("apply"); private static readonly JsString TrapGet = new JsString("get"); private static readonly JsString TrapSet = new JsString("set"); @@ -37,13 +45,32 @@ public JsProxy( Engine engine, ObjectInstance target, ObjectInstance handler) + : this(engine, target, handler, clrHandler: null) + { + } + + internal JsProxy( + Engine engine, + ObjectInstance target, + ProxyHandler clrHandler) + : this(engine, target, handler: null, clrHandler) + { + } + + private JsProxy( + Engine engine, + ObjectInstance target, + ObjectInstance? handler, + ProxyHandler? clrHandler) : base(engine, target.Class) { _type |= InternalTypes.ExoticGet; _target = target; _handler = handler; + _clrHandler = clrHandler; IsCallable = target.IsCallable; _isConstructor = target.IsConstructor; + _bubbleExceptions = engine.Options.Interop.ExceptionHandler == Options.InteropOptions._defaultExceptionHandler; } /// @@ -61,21 +88,34 @@ JsValue ICallable.Call(JsValue thisObject, params JsCallArguments arguments) } var target = _target; - var trap = GetTrap(TrapApply); - if (trap is null) + + if (_clrHandler is not null) { - var callable = target as ICallable; - if (callable is null) + var clrResult = InvokeClrApply(target, thisObject, CopyArgumentsForClrTrap(arguments)); + if (clrResult is not null) { - Throw.TypeError(_engine.Realm, target + " is not a function"); + // the spec has no apply-result invariant, no validation needed + return clrResult; } + } + else + { + var trap = GetTrap(TrapApply); + if (trap is not null) + { + // step 7: CreateArrayFromList(argumentsList) is only observable by the trap, built lazily + var argArray = _engine.Realm.Intrinsics.Array.ConstructFast(arguments); + return CallTrap(trap, target, thisObject, argArray); + } + } - return callable.Call(thisObject, arguments); + var callable = target as ICallable; + if (callable is null) + { + Throw.TypeError(_engine.Realm, target + " is not a function"); } - // step 7: CreateArrayFromList(argumentsList) is only observable by the trap, built lazily - var argArray = _engine.Realm.Intrinsics.Array.ConstructFast(arguments); - return CallTrap(trap, target, thisObject, argArray); + return callable.Call(thisObject, arguments); } /// @@ -91,30 +131,43 @@ ObjectInstance IConstructor.Construct(JsCallArguments arguments, JsValue newTarg } var target = _target; - var trap = GetTrap(TrapConstruct); - if (trap is null) + + if (_clrHandler is not null) { - var constructor = target as IConstructor; - if (constructor is null) + // the typed ObjectInstance? return enforces the object invariant at compile time + var clrResult = InvokeClrConstruct(target, CopyArgumentsForClrTrap(arguments), newTarget); + if (clrResult is not null) { - Throw.TypeError(_engine.Realm, "Proxy target is not a constructor"); + return clrResult; } - - return constructor.Construct(arguments, newTarget); } + else + { + var trap = GetTrap(TrapConstruct); + if (trap is not null) + { + // step 7: CreateArrayFromList(argumentsList) - must not use Array constructor semantics, + // a single numeric argument would create a hole-filled array of that length + var argArray = _engine.Realm.Intrinsics.Array.ConstructFast(arguments); + var result = CallTrap(trap, target, argArray, newTarget); - // step 7: CreateArrayFromList(argumentsList) - must not use Array constructor semantics, - // a single numeric argument would create a hole-filled array of that length - var argArray = _engine.Realm.Intrinsics.Array.ConstructFast(arguments); - var result = CallTrap(trap, target, argArray, newTarget); + var oi = result as ObjectInstance; + if (oi is null) + { + Throw.TypeError(_engine.Realm, $"'construct' on proxy: trap returned non-object ('{result}')"); + } - var oi = result as ObjectInstance; - if (oi is null) + return oi; + } + } + + var constructor = target as IConstructor; + if (constructor is null) { - Throw.TypeError(_engine.Realm, $"'construct' on proxy: trap returned non-object ('{result}')"); + Throw.TypeError(_engine.Realm, "Proxy target is not a constructor"); } - return oi; + return constructor.Construct(arguments, newTarget); } internal override bool IsArray() @@ -132,14 +185,31 @@ internal override bool IsArray() /// public override JsValue Get(JsValue property, JsValue receiver) { - var trap = GetTrap(TrapGet); + AssertNotRevoked(TrapGet); var target = _target; - if (trap is null) + + JsValue result; + if (_clrHandler is not null) + { + var clrResult = InvokeClrGet(target, TypeConverter.ToPropertyKey(property), receiver); + if (clrResult is null) + { + return target.Get(property, receiver); + } + + result = clrResult; + } + else { - return target.Get(property, receiver); + var trap = GetTrap(TrapGet); + if (trap is null) + { + return target.Get(property, receiver); + } + + result = CallTrap(trap, target, TypeConverter.ToPropertyKey(property), receiver); } - var result = CallTrap(trap, target, TypeConverter.ToPropertyKey(property), receiver); ValidateGetTrapResult(target, property, result); return result; } @@ -173,17 +243,53 @@ private void ValidateGetTrapResult(ObjectInstance target, JsValue property, JsVa /// public override List GetOwnPropertyKeys(Types types = Types.Empty | Types.String | Types.Symbol) { - var trap = GetTrap(TrapOwnKeys); + AssertNotRevoked(TrapOwnKeys); var target = _target; - if (trap is null) + + JsValue result; + if (_clrHandler is not null) { - return target.GetOwnPropertyKeys(types); + var clrResult = InvokeClrOwnKeys(target); + if (clrResult is null) + { + return target.GetOwnPropertyKeys(types); + } + + result = CreateOwnKeysArray(clrResult); + } + else + { + var trap = GetTrap(TrapOwnKeys); + if (trap is null) + { + return target.GetOwnPropertyKeys(types); + } + + result = CallTrap(trap, target); } - var result = CallTrap(trap, target); return ValidateOwnKeysTrapResult(target, result); } + private JsArray CreateOwnKeysArray(IReadOnlyList keys) + { + // element-wise copy: the handler may hand out a list it mutates later, and null + // elements must be rejected before the validator dereferences them + var copy = new JsValue[keys.Count]; + for (var i = 0; i < copy.Length; i++) + { + var key = keys[i]; + if (key is null) + { + Throw.TypeError(_engine.Realm, "'ownKeys' on proxy: CLR trap returned a null element"); + } + + copy[i] = key; + } + + return new JsArray(_engine, copy); + } + private List ValidateOwnKeysTrapResult(ObjectInstance target, JsValue result) { var keys = FunctionPrototype.CreateListFromArrayLike(_engine.Realm, result, Types.String | Types.Symbol); @@ -263,14 +369,32 @@ private List ValidateOwnKeysTrapResult(ObjectInstance target, JsValue r /// public override PropertyDescriptor GetOwnProperty(JsValue property) { - var trap = GetTrap(TrapGetOwnPropertyDescriptor); + AssertNotRevoked(TrapGetOwnPropertyDescriptor); var target = _target; - if (trap is null) + + JsValue trapResultObj; + if (_clrHandler is not null) { - return target.GetOwnProperty(property); + var clrResult = InvokeClrGetOwnPropertyDescriptor(target, TypeConverter.ToPropertyKey(property)); + if (clrResult is null) + { + return target.GetOwnProperty(property); + } + + // PropertyDescriptor.Undefined round-trips as JsValue.Undefined ("no such property") + trapResultObj = PropertyDescriptor.FromPropertyDescriptor(_engine, clrResult, strictUndefined: true); + } + else + { + var trap = GetTrap(TrapGetOwnPropertyDescriptor); + if (trap is null) + { + return target.GetOwnProperty(property); + } + + trapResultObj = CallTrap(trap, target, TypeConverter.ToPropertyKey(property)); } - var trapResultObj = CallTrap(trap, target, TypeConverter.ToPropertyKey(property)); return ValidateGetOwnPropertyTrapResult(target, property, trapResultObj); } @@ -367,17 +491,35 @@ private static void CompletePropertyDescriptor(PropertyDescriptor desc) /// public override bool Set(JsValue property, JsValue value, JsValue receiver) { - var trap = GetTrap(TrapSet); + AssertNotRevoked(TrapSet); var target = _target; - if (trap is null) + + if (_clrHandler is not null) { - return target.Set(property, value, receiver); - } + var clrResult = InvokeClrSet(target, TypeConverter.ToPropertyKey(property), value, receiver); + if (clrResult is null) + { + return target.Set(property, value, receiver); + } - var trapResult = CallTrap(trap, target, TypeConverter.ToPropertyKey(property), value, receiver); - if (!TypeConverter.ToBoolean(trapResult)) + if (!clrResult.Value) + { + return false; + } + } + else { - return false; + var trap = GetTrap(TrapSet); + if (trap is null) + { + return target.Set(property, value, receiver); + } + + var trapResult = CallTrap(trap, target, TypeConverter.ToPropertyKey(property), value, receiver); + if (!TypeConverter.ToBoolean(trapResult)) + { + return false; + } } ValidateSetTrapResult(target, property, value); @@ -413,18 +555,36 @@ private void ValidateSetTrapResult(ObjectInstance target, JsValue property, JsVa /// public override bool DefineOwnProperty(JsValue property, PropertyDescriptor desc) { - var trap = GetTrap(TrapDefineProperty); + AssertNotRevoked(TrapDefineProperty); var target = _target; - if (trap is null) + + if (_clrHandler is not null) { - return target.DefineOwnProperty(property, desc); - } + var clrResult = InvokeClrDefineProperty(target, TypeConverter.ToPropertyKey(property), desc); + if (clrResult is null) + { + return target.DefineOwnProperty(property, desc); + } - var descObj = PropertyDescriptor.FromPropertyDescriptor(_engine, desc, strictUndefined: true); - var result = CallTrap(trap, target, TypeConverter.ToPropertyKey(property), descObj); - if (!TypeConverter.ToBoolean(result)) + if (!clrResult.Value) + { + return false; + } + } + else { - return false; + var trap = GetTrap(TrapDefineProperty); + if (trap is null) + { + return target.DefineOwnProperty(property, desc); + } + + var descObj = PropertyDescriptor.FromPropertyDescriptor(_engine, desc, strictUndefined: true); + var result = CallTrap(trap, target, TypeConverter.ToPropertyKey(property), descObj); + if (!TypeConverter.ToBoolean(result)) + { + return false; + } } ValidateDefinePropertyTrapResult(target, property, desc); @@ -479,14 +639,31 @@ private static bool IsCompatiblePropertyDescriptor(bool extensible, PropertyDesc /// public override bool HasProperty(JsValue property) { - var trap = GetTrap(TrapHas); + AssertNotRevoked(TrapHas); var target = _target; - if (trap is null) + + bool trapResult; + if (_clrHandler is not null) { - return target.HasProperty(property); + var clrResult = InvokeClrHas(target, TypeConverter.ToPropertyKey(property)); + if (clrResult is null) + { + return target.HasProperty(property); + } + + trapResult = clrResult.Value; + } + else + { + var trap = GetTrap(TrapHas); + if (trap is null) + { + return target.HasProperty(property); + } + + trapResult = TypeConverter.ToBoolean(CallTrap(trap, target, TypeConverter.ToPropertyKey(property))); } - var trapResult = TypeConverter.ToBoolean(CallTrap(trap, target, TypeConverter.ToPropertyKey(property))); if (!trapResult) { ValidateHasTrapResult(target, property); @@ -517,16 +694,34 @@ private void ValidateHasTrapResult(ObjectInstance target, JsValue property) /// public override bool Delete(JsValue property) { - var trap = GetTrap(TrapDeleteProperty); + AssertNotRevoked(TrapDeleteProperty); var target = _target; - if (trap is null) + + if (_clrHandler is not null) { - return target.Delete(property); - } + var clrResult = InvokeClrDeleteProperty(target, TypeConverter.ToPropertyKey(property)); + if (clrResult is null) + { + return target.Delete(property); + } - if (!TypeConverter.ToBoolean(CallTrap(trap, target, TypeConverter.ToPropertyKey(property)))) + if (!clrResult.Value) + { + return false; + } + } + else { - return false; + var trap = GetTrap(TrapDeleteProperty); + if (trap is null) + { + return target.Delete(property); + } + + if (!TypeConverter.ToBoolean(CallTrap(trap, target, TypeConverter.ToPropertyKey(property)))) + { + return false; + } } ValidateDeleteTrapResult(target, property); @@ -558,14 +753,31 @@ private void ValidateDeleteTrapResult(ObjectInstance target, JsValue property) /// public override bool PreventExtensions() { - var trap = GetTrap(TrapPreventExtensions); + AssertNotRevoked(TrapPreventExtensions); var target = _target; - if (trap is null) + + bool success; + if (_clrHandler is not null) { - return target.PreventExtensions(); + var clrResult = InvokeClrPreventExtensions(target); + if (clrResult is null) + { + return target.PreventExtensions(); + } + + success = clrResult.Value; + } + else + { + var trap = GetTrap(TrapPreventExtensions); + if (trap is null) + { + return target.PreventExtensions(); + } + + success = TypeConverter.ToBoolean(CallTrap(trap, target)); } - var success = TypeConverter.ToBoolean(CallTrap(trap, target)); if (success && target.Extensible) { Throw.TypeError(_engine.Realm, "'preventExtensions' on proxy: trap returned truish but the proxy target is extensible"); @@ -581,14 +793,31 @@ public override bool Extensible { get { - var trap = GetTrap(TrapIsExtensible); + AssertNotRevoked(TrapIsExtensible); var target = _target; - if (trap is null) + + bool booleanTrapResult; + if (_clrHandler is not null) { - return target.Extensible; + var clrResult = InvokeClrIsExtensible(target); + if (clrResult is null) + { + return target.Extensible; + } + + booleanTrapResult = clrResult.Value; + } + else + { + var trap = GetTrap(TrapIsExtensible); + if (trap is null) + { + return target.Extensible; + } + + booleanTrapResult = TypeConverter.ToBoolean(CallTrap(trap, target)); } - var booleanTrapResult = TypeConverter.ToBoolean(CallTrap(trap, target)); var targetResult = target.Extensible; if (booleanTrapResult != targetResult) { @@ -604,14 +833,31 @@ public override bool Extensible /// protected internal override ObjectInstance? GetPrototypeOf() { - var trap = GetTrap(TrapGetProtoTypeOf); + AssertNotRevoked(TrapGetProtoTypeOf); var target = _target; - if (trap is null) + + JsValue handlerProto; + if (_clrHandler is not null) { - return target.Prototype; + var clrResult = InvokeClrGetPrototypeOf(target); + if (clrResult is null) + { + return target.Prototype; + } + + handlerProto = clrResult; + } + else + { + var trap = GetTrap(TrapGetProtoTypeOf); + if (trap is null) + { + return target.Prototype; + } + + handlerProto = CallTrap(trap, target); } - var handlerProto = CallTrap(trap, target); if (!handlerProto.IsObject() && !handlerProto.IsNull()) { Throw.TypeError(_engine.Realm, "'getPrototypeOf' on proxy: trap returned neither object nor null"); @@ -637,14 +883,31 @@ public override bool Extensible /// internal override bool SetPrototypeOf(JsValue value) { - var trap = GetTrap(TrapSetProtoTypeOf); + AssertNotRevoked(TrapSetProtoTypeOf); var target = _target; - if (trap is null) + + bool success; + if (_clrHandler is not null) { - return target.SetPrototypeOf(value); + var clrResult = InvokeClrSetPrototypeOf(target, value); + if (clrResult is null) + { + return target.SetPrototypeOf(value); + } + + success = clrResult.Value; + } + else + { + var trap = GetTrap(TrapSetProtoTypeOf); + if (trap is null) + { + return target.SetPrototypeOf(value); + } + + success = TypeConverter.ToBoolean(CallTrap(trap, target, value)); } - var success = TypeConverter.ToBoolean(CallTrap(trap, target, value)); if (!success) { return false; @@ -668,16 +931,15 @@ internal override bool SetPrototypeOf(JsValue value) internal override bool IsCallable { get; } /// - /// Shared trap fetch: revocation check followed by GetMethod(handler, trapName). Returns null - /// when the trap is absent (caller forwards to the target). The fetch is spec-observable and + /// Shared trap fetch: GetMethod(handler, trapName). Returns null when the trap is absent + /// (caller forwards to the target). Callers must have called AssertNotRevoked first and only + /// call this when the proxy uses a JavaScript handler object. The fetch is spec-observable and /// must happen fresh on every operation - the handler may be a proxy itself or use getters, /// and traps may be added or removed between operations, so the result must never be cached. /// https://tc39.es/ecma262/#sec-getmethod /// private ICallable? GetTrap(JsString trapName) { - AssertNotRevoked(trapName); - var handlerFunction = _handler!.Get(trapName); if (handlerFunction.IsNullOrUndefined()) { @@ -745,11 +1007,233 @@ private JsValue CallTrap(ICallable trap, JsValue arg0, JsValue arg1, JsValue arg private void AssertNotRevoked(JsValue key) { - if (_handler is null) + if (_target is null) { Throw.TypeError(_engine.Realm, $"Cannot perform '{key}' on a proxy that has been revoked"); } } + internal bool IsRevoked => _target is null; + + /// + /// https://tc39.es/ecma262/#sec-proxy-revocation-functions + /// Revokes the proxy: subsequent trap operations throw a TypeError. Idempotent. + /// + internal void Revoke() + { + _target = null!; + _handler = null; + _clrHandler = null; + } + + /// + /// Interpreter call sites hand us pooled argument arrays that may also be object[] instances + /// reinterpreted via Unsafe.As; a CLR trap is user code that may legitimately hold on to (or + /// reflect over) its arguments, so it must receive a private, genuine JsValue[] copy built + /// element-wise. + /// + private static JsValue[] CopyArgumentsForClrTrap(JsCallArguments arguments) + { + if (arguments.Length == 0) + { + return []; + } + + var copy = new JsValue[arguments.Length]; + for (var i = 0; i < arguments.Length; i++) + { + copy[i] = arguments[i]; + } + + return copy; + } + + // CLR trap invocation wrappers. Exception routing mirrors ClrFunction: with the default + // (bubbling) exception handler the catch filter never engages and user exceptions propagate + // untouched; otherwise Options.Interop.ExceptionHandler decides whether the exception becomes + // a catchable JavaScript error. The try/catch is free on the non-throwing path. + + private JsValue? InvokeClrGet(ObjectInstance target, JsValue property, JsValue receiver) + { + try + { + return _clrHandler!.Get(target, property, receiver); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private bool? InvokeClrSet(ObjectInstance target, JsValue property, JsValue value, JsValue receiver) + { + try + { + return _clrHandler!.Set(target, property, value, receiver); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private bool? InvokeClrHas(ObjectInstance target, JsValue property) + { + try + { + return _clrHandler!.Has(target, property); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private bool? InvokeClrDeleteProperty(ObjectInstance target, JsValue property) + { + try + { + return _clrHandler!.DeleteProperty(target, property); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private PropertyDescriptor? InvokeClrGetOwnPropertyDescriptor(ObjectInstance target, JsValue property) + { + try + { + return _clrHandler!.GetOwnPropertyDescriptor(target, property); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private bool? InvokeClrDefineProperty(ObjectInstance target, JsValue property, PropertyDescriptor descriptor) + { + try + { + return _clrHandler!.DefineProperty(target, property, descriptor); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private IReadOnlyList? InvokeClrOwnKeys(ObjectInstance target) + { + try + { + return _clrHandler!.OwnKeys(target); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private JsValue? InvokeClrApply(ObjectInstance target, JsValue thisObject, JsCallArguments arguments) + { + try + { + return _clrHandler!.Apply(target, thisObject, arguments); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private ObjectInstance? InvokeClrConstruct(ObjectInstance target, JsCallArguments arguments, JsValue newTarget) + { + try + { + return _clrHandler!.Construct(target, arguments, newTarget); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private JsValue? InvokeClrGetPrototypeOf(ObjectInstance target) + { + try + { + return _clrHandler!.GetPrototypeOf(target); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private bool? InvokeClrSetPrototypeOf(ObjectInstance target, JsValue prototype) + { + try + { + return _clrHandler!.SetPrototypeOf(target, prototype); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private bool? InvokeClrIsExtensible(ObjectInstance target) + { + try + { + return _clrHandler!.IsExtensible(target); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + private bool? InvokeClrPreventExtensions(ObjectInstance target) + { + try + { + return _clrHandler!.PreventExtensions(target); + } + catch (Exception e) when (e is not JavaScriptException && !_bubbleExceptions) + { + RethrowClrTrapException(e); + return null; + } + } + + [DoesNotReturn] + private void RethrowClrTrapException(Exception exception) + { + if (_engine.Options.Interop.ExceptionHandler(exception)) + { + Throw.FromClrException(_engine, exception); + } + + ExceptionDispatchInfo.Capture(exception).Throw(); +#pragma warning disable CS8763 + } +#pragma warning restore CS8763 + public override string ToString() => IsCallable ? "function () { [native code] }" : base.ToString(); } diff --git a/Jint/Native/Proxy/ProxyConstructor.cs b/Jint/Native/Proxy/ProxyConstructor.cs index 7ab1b110f..ee9628c3f 100644 --- a/Jint/Native/Proxy/ProxyConstructor.cs +++ b/Jint/Native/Proxy/ProxyConstructor.cs @@ -60,8 +60,7 @@ private JsValue Revocable(JsValue thisObject, JsValue target, JsValue handler) JsValue Revoke(JsValue thisObject, JsCallArguments arguments) { - p._handler = null; - p._target = null!; + p.Revoke(); return Undefined; } diff --git a/Jint/Runtime/Interop/ProxyHandler.cs b/Jint/Runtime/Interop/ProxyHandler.cs new file mode 100644 index 000000000..195336866 --- /dev/null +++ b/Jint/Runtime/Interop/ProxyHandler.cs @@ -0,0 +1,176 @@ +using Jint.Native; +using Jint.Native.Object; +using Jint.Runtime.Descriptors; + +namespace Jint.Runtime.Interop; + +/// +/// Allows .NET code to implement ECMAScript Proxy traps. Create proxies via +/// and . +/// +/// +/// +/// A trap method returning CLR means "trap not implemented — forward to the target", +/// exactly like an absent trap property on a JavaScript handler object. A trap may also decide per-invocation +/// to return to forward that particular operation. +/// +/// +/// All ECMAScript proxy invariants are enforced on trap results, the same way they are for JavaScript +/// handler objects (a lying trap produces a TypeError). +/// +/// +/// A handler instance is not bound to an engine; if shared across engines it must be safe for use from +/// each engine's thread. +/// +/// +public abstract class ProxyHandler +{ + /// + /// The get trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-get-p-receiver. + /// Intercepts property reads. Return the property value, or to forward to the target. + /// Returning is a real trap result (the property reads as undefined), not a forward. + /// + /// + /// Note the ECMAScript method-call semantics: proxy.method(x) first fires this get trap + /// (returning the method) and then performs a plain call on the returned value — the + /// trap only fires when the proxy itself is invoked. To intercept method calls, return a wrapping + /// function from this trap; memoize the wrappers so that proxy.method === proxy.method holds. + /// + /// The proxy target. + /// The canonicalized property key (a or ). + /// The receiver of the read, usually the proxy itself (differs e.g. for Reflect.get with an explicit receiver). + public virtual JsValue? Get(ObjectInstance target, JsValue property, JsValue receiver) => null; + + /// + /// The set trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-set-p-v-receiver. + /// Intercepts property writes. Return when the write succeeded, + /// to reject it (a TypeError in strict mode code), or to forward to the target. + /// + /// The proxy target. + /// The canonicalized property key (a or ). + /// The value being written. + /// The receiver of the write, usually the proxy itself. + public virtual bool? Set(ObjectInstance target, JsValue property, JsValue value, JsValue receiver) => null; + + /// + /// The has trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-hasproperty-p. + /// Intercepts the in operator and other [[HasProperty]] probes. Return whether the property + /// should be reported as present, or to forward to the target. + /// + /// The proxy target. + /// The canonicalized property key (a or ). + public virtual bool? Has(ObjectInstance target, JsValue property) => null; + + /// + /// The deleteProperty trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-delete-p. + /// Intercepts the delete operator. Return whether the deletion succeeded, or + /// to forward to the target. + /// + /// The proxy target. + /// The canonicalized property key (a or ). + public virtual bool? DeleteProperty(ObjectInstance target, JsValue property) => null; + + /// + /// The getOwnPropertyDescriptor trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-getownproperty-p. + /// Return a descriptor for the property, to report that no such + /// own property exists, or to forward to the target. + /// + /// The proxy target. + /// The canonicalized property key (a or ). + public virtual PropertyDescriptor? GetOwnPropertyDescriptor(ObjectInstance target, JsValue property) => null; + + /// + /// The defineProperty trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-defineownproperty-p-desc. + /// Intercepts Object.defineProperty and friends. Return whether the definition succeeded, + /// or to forward to the target. + /// + /// The proxy target. + /// The canonicalized property key (a or ). + /// The descriptor being defined. + public virtual bool? DefineProperty(ObjectInstance target, JsValue property, PropertyDescriptor descriptor) => null; + + /// + /// The ownKeys trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-ownpropertykeys. + /// Intercepts own-key enumeration (Object.keys, Object.getOwnPropertyNames, spread, for...in, ...). + /// Return the list of own property keys (each a or , without duplicates), + /// or to forward to the target. + /// + /// The proxy target. + public virtual IReadOnlyList? OwnKeys(ObjectInstance target) => null; + + /// + /// The apply trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-call-thisargument-argumentslist. + /// Intercepts calling the proxy itself (only reachable when the target is callable, per spec). + /// Return the call result, or to forward the call to the target. + /// Note that proxy.method(x) does not fire this trap — see . + /// + /// The proxy target. + /// The this value of the call. + /// The call arguments; the array is a copy private to this invocation. + public virtual JsValue? Apply(ObjectInstance target, JsValue thisObject, JsValue[] arguments) => null; + + /// + /// The construct trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-construct-argumentslist-newtarget. + /// Intercepts new proxy(...) (only reachable when the target is a constructor, per spec). + /// Return the constructed object, or to forward to the target constructor. + /// + /// The proxy target. + /// The constructor arguments; the array is a copy private to this invocation. + /// The new.target value. + public virtual ObjectInstance? Construct(ObjectInstance target, JsValue[] arguments, JsValue newTarget) => null; + + /// + /// The getPrototypeOf trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-getprototypeof. + /// Return the prototype object, to report a null prototype, + /// or CLR to forward to the target. + /// + /// The proxy target. + public virtual JsValue? GetPrototypeOf(ObjectInstance target) => null; + + /// + /// The setPrototypeOf trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-setprototypeof-v. + /// Return whether the prototype change succeeded, or to forward to the target. + /// + /// The proxy target. + /// The new prototype: an object or . + public virtual bool? SetPrototypeOf(ObjectInstance target, JsValue prototype) => null; + + /// + /// The isExtensible trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-isextensible. + /// Return whether the object is extensible (the result must match the target's actual extensibility, per spec), + /// or to forward to the target. + /// + /// The proxy target. + public virtual bool? IsExtensible(ObjectInstance target) => null; + + /// + /// The preventExtensions trap, https://tc39.es/ecma262/#sec-proxy-object-internal-methods-and-internal-slots-preventextensions. + /// Return whether extensions were prevented (returning requires the target to actually + /// be non-extensible, per spec), or to forward to the target. + /// + /// The proxy target. + public virtual bool? PreventExtensions(ObjectInstance target) => null; +} + +/// +/// Result of , mirroring JavaScript's Proxy.revocable(). +/// +public readonly struct RevocableProxy +{ + private readonly JsProxy _proxy; + + internal RevocableProxy(JsProxy proxy) + { + _proxy = proxy; + } + + /// + /// The proxy object. + /// + public ObjectInstance Proxy => _proxy; + + /// + /// Revokes the proxy: subsequent trap operations throw a TypeError. Idempotent. + /// + public void Revoke() => _proxy.Revoke(); +} diff --git a/README.md b/README.md index 9f63b9d1a..db334f804 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,34 @@ jint> list.Add(1); // automatically converted to String jint> list.Count; // 2 ``` +### Intercepting access to .NET objects + +ECMAScript Proxy traps can be implemented in .NET by deriving from `ProxyHandler` and creating the proxy via `engine.Advanced.CreateProxy` (or `CreateRevocableProxy`). A trap returning `null` forwards the operation to the target, exactly like an absent trap on a JavaScript handler object, and all proxy invariants are enforced on trap results. Combine with `SetWrapObjectHandler` to transparently intercept every wrapped .NET object. Note that `proxy.method()` fires the `Get` trap (then calls the returned value) — the `Apply` trap only fires when the proxy itself is invoked, so intercepting method calls means returning a wrapping function from `Get`. `new Proxy(target, handlerObject)` from script remains the JavaScript-side equivalent. + +```c# +class LoggingHandler : ProxyHandler +{ + public override JsValue? Get(ObjectInstance target, JsValue property, JsValue receiver) + { + Console.WriteLine($"get {property}"); + return null; // forward to the target + } + + public override bool? Has(ObjectInstance target, JsValue property) + { + Console.WriteLine($"has {property}"); + return null; + } +} + +var engine = new Engine(options => +{ + // wrap every interop object in a logging proxy + options.SetWrapObjectHandler((e, obj, type) => + e.Advanced.CreateProxy(ObjectWrapper.Create(e, obj, type), new LoggingHandler())); +}); +``` + ## Internationalization You can enforce what Time Zone or Culture the engine should use when locale JavaScript methods are used if you don't want to use the computer's default values.