Skip to content
Open
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
12 changes: 6 additions & 6 deletions src/NodeApi/JSError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,22 +290,22 @@ public static void ThrowError(string code, string message)
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowError(env, code, message);

public static void ThrowTypeError(string message)
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowError(env, code: null, message);
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowTypeError(env, code: null, message);

public static void ThrowTypeError(string code, string message)
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowError(env, code, message);
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowTypeError(env, code, message);

public static void ThrowRangeError(string message)
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowError(env, code: null, message);
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowRangeError(env, code: null, message);

public static void ThrowRangeError(string code, string message)
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowError(env, code, message);
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowRangeError(env, code, message);

public static void ThrowSyntaxError(string message)
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowError(env, code: null, message);
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowSyntaxError(env, code: null, message);

public static void ThrowSyntaxError(string code, string message)
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowError(env, code, message);
=> JSValue.GetCurrentRuntime(out napi_env env).ThrowSyntaxError(env, code, message);

/// <summary>
/// Gets a JS error stack trace that also includes a .NET stack trace,
Expand Down
2 changes: 1 addition & 1 deletion src/NodeApi/Runtime/NodejsRuntime.Embedding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ public override NodeEmbeddingStatus EmbeddingRuntimeConfigAddModule(
node_embedding_data_release_callback release_init_module_data,
int module_node_api_version)
{
PooledBuffer moduleNameBuffer = PooledBuffer.FromSpanUtf8(module_name);
using PooledBuffer moduleNameBuffer = PooledBuffer.FromSpanUtf8(module_name);
fixed (byte* moduleNamePtr = moduleNameBuffer)
return Import(ref node_embedding_runtime_config_add_module)(
runtime_config,
Expand Down
17 changes: 13 additions & 4 deletions src/NodeApi/Runtime/PooledBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,33 +68,42 @@ public void Dispose()
[EditorBrowsable(EditorBrowsableState.Never)]
public readonly ref byte GetPinnableReference() => ref Span.GetPinnableReference();

// Native callers may read these buffers as null-terminated C strings. A null string
// produces an empty buffer (null pointer); an empty string produces just the terminator.
public static unsafe PooledBuffer FromStringUtf8(string? value)
{
if (string.IsNullOrEmpty(value))
if (value == null)
{
return Empty;
}

int byteLength = Encoding.UTF8.GetByteCount(value);
PooledBuffer buffer = new(byteLength, byteLength + 1);
Encoding.UTF8.GetBytes(value, 0, value!.Length, buffer.Buffer, 0);
Encoding.UTF8.GetBytes(value, 0, value.Length, buffer.Buffer, 0);

// Pooled arrays are not zeroed.
buffer.Buffer[byteLength] = 0;
return buffer;
}

public static unsafe PooledBuffer FromSpanUtf8(ReadOnlySpan<char> value)
{
if (value.IsEmpty)
{
return Empty;
PooledBuffer emptyString = new(0, 1);
emptyString.Buffer[0] = 0;
return emptyString;
}

fixed (char* valuePtr = value)
{
int byteLength = Encoding.UTF8.GetByteCount(valuePtr, value.Length);
PooledBuffer buffer = new(byteLength, byteLength + 1);
fixed (byte* bufferPtr = buffer.Span)
Encoding.UTF8.GetBytes(valuePtr, value.Length, bufferPtr, byteLength + 1);
Encoding.UTF8.GetBytes(valuePtr, value.Length, bufferPtr, byteLength);

// Pooled arrays are not zeroed.
buffer.Buffer[byteLength] = 0;
return buffer;
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/NodeApi/Runtime/Utf8StringArray.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ public unsafe Utf8StringArray(ReadOnlySpan<string> strings)
{
Utf8Strings[i] = stringBufferPtr + offset;
offset += Encoding.UTF8.GetBytes(
src, strings[i].Length, (byte*)(stringBufferPtr + offset), byteLength - offset)
+ 1; // +1 for the string Null-terminator.
src, strings[i].Length, (byte*)(stringBufferPtr + offset), byteLength - offset);

// Pooled arrays are not zeroed.
_stringBuffer[offset++] = 0;
}
}
}
Expand Down
83 changes: 83 additions & 0 deletions test/NodejsEmbeddingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,89 @@ public void ErrorPropagation()
$"at {typeof(NodejsEmbeddingTests).FullName}.", StringComparison.Ordinal));
}

[Theory]
[InlineData("x")]
[InlineData("")]
public void ThrowErrorFromCallback(string message)
{
using NodeEmbeddingThreadRuntime nodejs = CreateNodeEmbeddingThreadRuntime();

nodejs.Run(() =>
{
JSValue.Global.SetProperty("throwError", JSValue.CreateFunction("throwError", (args) =>
{
// Rented buffers that are not zeroed must not leak into the error message.
FillArrayPoolWithGarbage();
JSError.ThrowError((string)args[0]);
return JSValue.Undefined;
}));
JSValue.Global.SetProperty("message", message);

JSValue result = JSValue.RunScript("""
(() => {
try { throwError(message); } catch (e) { return e.message; }
return 'not thrown';
})()
""");
Assert.Equal(message, (string)result);
});
}

[Theory]
[InlineData("Error", null)]
[InlineData("Error", "ERR_TEST")]
[InlineData("TypeError", null)]
[InlineData("TypeError", "ERR_TEST")]
[InlineData("RangeError", null)]
[InlineData("RangeError", "ERR_TEST")]
[InlineData("SyntaxError", null)]
[InlineData("SyntaxError", "ERR_TEST")]
public void ThrowTypedError(string errorType, string? code)
{
using NodeEmbeddingThreadRuntime nodejs = CreateNodeEmbeddingThreadRuntime();

nodejs.Run(() =>
{
JSValue.Global.SetProperty("throwError", JSValue.CreateFunction("throwError", (_) =>
{
Action<string> throwWithoutCode = errorType switch
{
"Error" => JSError.ThrowError,
"TypeError" => JSError.ThrowTypeError,
"RangeError" => JSError.ThrowRangeError,
"SyntaxError" => JSError.ThrowSyntaxError,
_ => throw new ArgumentOutOfRangeException(nameof(errorType)),
};
Action<string, string> throwWithCode = errorType switch
{
"Error" => JSError.ThrowError,
"TypeError" => JSError.ThrowTypeError,
"RangeError" => JSError.ThrowRangeError,
"SyntaxError" => JSError.ThrowSyntaxError,
_ => throw new ArgumentOutOfRangeException(nameof(errorType)),
};

if (code == null) throwWithoutCode("test");
else throwWithCode(code, "test");
return JSValue.Undefined;
}));

JSValue error = JSValue.RunScript("""
(() => {
try { throwError(); } catch (e) { return e; }
return null;
})()
""");
Assert.True(error.IsObject());
Assert.Equal(errorType, (string)error["name"]);
Assert.True((bool)JSValue.RunScript(
$"(e) => e instanceof {errorType}").Call(JSValue.Undefined, error));
Assert.Equal("test", (string)error["message"]);
if (code == null) Assert.True(error["code"].IsUndefined());
else Assert.Equal(code, (string)error["code"]);
});
}

[Fact]
public async Task WorkerIsMainThread()
{
Expand Down
29 changes: 29 additions & 0 deletions test/TestUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
// Licensed under the MIT License.

using System;
#if !NETFRAMEWORK
using System.Buffers;
#endif
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
Expand Down Expand Up @@ -142,6 +146,31 @@ public static void CopyIfNewer(string sourceFilePath, string targetFilePath)
}
}

/// <summary>
/// Fills arrays in the shared byte array pool (as seen by the calling thread) with non-zero
/// bytes, so that code relying on rented buffers being zeroed fails deterministically.
/// </summary>
public static void FillArrayPoolWithGarbage()
{
#if !NETFRAMEWORK
List<byte[]> rented = new();
for (int size = 1; size <= 1024; size *= 2)
{
for (int i = 0; i < 8; i++)
{
byte[] array = ArrayPool<byte>.Shared.Rent(size);
array.AsSpan().Fill(0xAA);
rented.Add(array);
}
}

foreach (byte[] array in rented)
{
ArrayPool<byte>.Shared.Return(array);
}
#endif
}

public static Task RunInThread(Action action)
{
TaskCompletionSource<bool> threadCompletion = new();
Expand Down
76 changes: 76 additions & 0 deletions test/Utf8BufferTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.JavaScript.NodeApi.Runtime;
using Xunit;
using static Microsoft.JavaScript.NodeApi.Test.TestUtils;

namespace Microsoft.JavaScript.NodeApi.Test;

public class Utf8BufferTests
{
public static TheoryData<string> Strings { get; } = new()
{
"",
"x",
"module-name",
"\u00e4\u00f6\u00fc \u4e2d\u6587 \U0001F600",
};

[Theory]
[MemberData(nameof(Strings))]
public void FromStringUtf8IsNullTerminated(string value)
{
FillArrayPoolWithGarbage();
using PooledBuffer buffer = PooledBuffer.FromStringUtf8(value);
AssertNullTerminatedUtf8(value, buffer);
}

[Theory]
[MemberData(nameof(Strings))]
public void FromSpanUtf8IsNullTerminated(string value)
{
FillArrayPoolWithGarbage();
using PooledBuffer buffer = PooledBuffer.FromSpanUtf8(value.AsSpan());
AssertNullTerminatedUtf8(value, buffer);
}

[Fact]
public void FromStringUtf8NullIsEmpty()
{
// A null string must still produce a null pointer when pinned.
using PooledBuffer buffer = PooledBuffer.FromStringUtf8(null);
Assert.Equal(0, buffer.Length);
Assert.True(buffer.Span.IsEmpty);
}

#if !NETFRAMEWORK
[Fact]
public void Utf8StringArrayIsNullTerminated()
{
string[] strings = ["node", "", "--expose-gc", "\u00e4\u00f6\u00fc \U0001F600"];

FillArrayPoolWithGarbage();
using Utf8StringArray utf8Strings = new(strings);

for (int i = 0; i < strings.Length; i++)
{
Assert.Equal(strings[i], Marshal.PtrToStringUTF8(utf8Strings.Utf8Strings[i]));
}
}
#endif

private static void AssertNullTerminatedUtf8(string expected, PooledBuffer buffer)
{
int byteLength = Encoding.UTF8.GetByteCount(expected);
Assert.Equal(byteLength, buffer.Length);

// The buffer must hold the terminator so that pinning it never yields a null pointer.
Assert.True(buffer.Span.Length > byteLength);
Assert.Equal(0, buffer.Span[byteLength]);
Assert.Equal(expected, Encoding.UTF8.GetString(buffer.Buffer, 0, byteLength));
}
}
Loading