diff --git a/Jint.Tests.PublicInterface/WebApiBase64Tests.cs b/Jint.Tests.PublicInterface/WebApiBase64Tests.cs new file mode 100644 index 0000000000..03a48189af --- /dev/null +++ b/Jint.Tests.PublicInterface/WebApiBase64Tests.cs @@ -0,0 +1,106 @@ +#if NET8_0_OR_GREATER +using Jint; +using Jint.Native; +using Jint.Runtime; + +namespace Jint.Tests.PublicInterface; + +/// +/// The Base64 web API feature seen from outside the assembly: what a host writes to get +/// atob and btoa, what it gets when it writes nothing, and the property shape a script +/// finds them in. +/// +public class WebApiBase64Tests +{ + [Fact] + public void ADefaultEngineHasNeitherFunction() + { + var engine = new Engine(); + + engine.Evaluate("typeof atob").AsString().Should().Be("undefined"); + engine.Evaluate("typeof btoa").AsString().Should().Be("undefined"); + engine.Evaluate("'btoa' in globalThis").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void TheFeatureFlagInstallsBoth() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64)); + + engine.Evaluate("btoa('hello')").AsString().Should().Be("aGVsbG8="); + engine.Evaluate("atob('aGVsbG8=')").AsString().Should().Be("hello"); + + // Asking for base64 alone does not bring anything else along. + engine.Evaluate("typeof TextEncoder").AsString().Should().Be("undefined"); + engine.Evaluate("typeof console").AsString().Should().Be("undefined"); + } + + [Fact] + public void TheDefaultSetIncludesIt() + { + var engine = new Engine(options => options.UseWebApis()); + + engine.Evaluate("typeof atob").AsString().Should().Be("function"); + engine.Evaluate("typeof btoa").AsString().Should().Be("function"); + } + + [Fact] + public void GivesTheFunctionsTheAttributesWebIdlAsksFor() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64)); + + // Operations of a WebIDL interface mixin are enumerable, unlike interface objects — + // https://webidl.spec.whatwg.org/#es-operations. + foreach (var name in new[] { "atob", "btoa" }) + { + engine.SetValue("name", name); + var descriptor = engine.Evaluate("Object.getOwnPropertyDescriptor(globalThis, name)").AsObject(); + + descriptor.Get("writable").AsBoolean().Should().BeTrue(); + descriptor.Get("configurable").AsBoolean().Should().BeTrue(); + descriptor.Get("enumerable").AsBoolean().Should().BeTrue(); + } + } + + [Fact] + public void AHostRegisteredGlobalWins() + { + var marker = new JsString("host's own btoa"); + + var engine = new Engine(options => options + .AddLazyGlobal("btoa", _ => marker) + .UseWebApis(WebApiFeatures.Base64)); + + engine.Evaluate("btoa").Should().BeSameAs(marker); + engine.Evaluate("typeof atob").AsString().Should().Be("function"); + } + + [Fact] + public void ReportsAFailureAsADomExceptionAHostCanRead() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64)); + + var exception = Assert.Throws(() => engine.Evaluate("btoa('\\u20ac')")); + + exception.Error.Get("name").AsString().Should().Be("InvalidCharacterError"); + exception.Error.Get("code").AsNumber().Should().Be(5); + + // It is a real Error, so a host logging it gets a message and a stack. + engine.SetValue("thrown", exception.Error); + engine.Evaluate("thrown instanceof Error").AsBoolean().Should().BeTrue(); + engine.Evaluate("typeof thrown.stack").AsString().Should().Be("string"); + } + + [Fact] + public void SurvivesAGlobalSnapshotRestore() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64)); + + var snapshot = engine.Advanced.CaptureGlobalSnapshot(); + engine.Evaluate("btoa('first')"); + engine.Advanced.RestoreGlobalSnapshot(snapshot); + + engine.Evaluate("btoa('second')").AsString().Should().Be("c2Vjb25k"); + } +} +#endif diff --git a/Jint.Tests.PublicInterface/WebApiEncodingTests.cs b/Jint.Tests.PublicInterface/WebApiEncodingTests.cs new file mode 100644 index 0000000000..c046303015 --- /dev/null +++ b/Jint.Tests.PublicInterface/WebApiEncodingTests.cs @@ -0,0 +1,115 @@ +#if NET8_0_OR_GREATER +using Jint; +using Jint.Native; + +namespace Jint.Tests.PublicInterface; + +/// +/// The Encoding web API feature seen from outside the assembly: what a host writes to get +/// TextEncoder and TextDecoder, what it gets when it writes nothing, and the property shape +/// a script finds them in. +/// +public class WebApiEncodingTests +{ + [Fact] + public void ADefaultEngineHasNeitherInterface() + { + var engine = new Engine(); + + engine.Evaluate("typeof TextEncoder").AsString().Should().Be("undefined"); + engine.Evaluate("typeof TextDecoder").AsString().Should().Be("undefined"); + engine.Evaluate("'TextEncoder' in globalThis").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void TheFeatureFlagInstallsBoth() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Encoding)); + + engine.Evaluate("typeof TextEncoder").AsString().Should().Be("function"); + engine.Evaluate("typeof TextDecoder").AsString().Should().Be("function"); + + // Asking for encoding alone does not bring anything else along. + engine.Evaluate("typeof console").AsString().Should().Be("undefined"); + engine.Evaluate("typeof atob").AsString().Should().Be("undefined"); + + // DOMException comes with any feature, since it is how they all report failure. + engine.Evaluate("typeof DOMException").AsString().Should().Be("function"); + } + + [Fact] + public void TheDefaultSetIncludesIt() + { + var engine = new Engine(options => options.UseWebApis()); + + engine.Evaluate("typeof TextEncoder").AsString().Should().Be("function"); + engine.Evaluate("typeof TextDecoder").AsString().Should().Be("function"); + } + + [Fact] + public void AnotherFeatureAloneDoesNotInstallThem() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64)); + + engine.Evaluate("typeof TextEncoder").AsString().Should().Be("undefined"); + engine.Evaluate("typeof TextDecoder").AsString().Should().Be("undefined"); + } + + [Fact] + public void GivesTheInterfaceObjectsTheAttributesWebIdlAsksFor() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Encoding)); + + // An interface object is writable and configurable but not enumerable — + // https://webidl.spec.whatwg.org/#es-interfaces. + foreach (var name in new[] { "TextEncoder", "TextDecoder" }) + { + engine.SetValue("name", name); + var descriptor = engine.Evaluate("Object.getOwnPropertyDescriptor(globalThis, name)").AsObject(); + + descriptor.Get("writable").AsBoolean().Should().BeTrue(); + descriptor.Get("configurable").AsBoolean().Should().BeTrue(); + descriptor.Get("enumerable").AsBoolean().Should().BeFalse(); + } + } + + [Fact] + public void AHostRegisteredGlobalWins() + { + var marker = new JsString("host's own TextEncoder"); + + var engine = new Engine(options => options + .AddLazyGlobal("TextEncoder", _ => marker) + .UseWebApis(WebApiFeatures.Encoding)); + + // The host's configuration runs first and the install is non-clobbering. + engine.Evaluate("TextEncoder").Should().BeSameAs(marker); + + // ... and the name it did not claim is still installed. + engine.Evaluate("typeof TextDecoder").AsString().Should().Be("function"); + } + + [Fact] + public void EncodesAndDecodesForTheHost() + { + var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Encoding)); + + engine.Evaluate("new TextEncoder().encode('Grüße').join(',')").AsString().Should().Be("71,114,195,188,195,159,101"); + engine.Evaluate("new TextDecoder().decode(new Uint8Array([71, 114, 195, 188, 195, 159, 101]))").AsString().Should().Be("Grüße"); + } + + [Fact] + public void OneOptionsInstanceServesSeveralEngines() + { + var options = new Options().UseWebApis(WebApiFeatures.Encoding); + + var first = new Engine(options); + var second = new Engine(options); + + // Decoder state is per instance and therefore per engine; nothing lives on the shared options. + first.Evaluate("var d = new TextDecoder(); d.decode(new Uint8Array([0xE2, 0x82]), { stream: true });"); + second.Evaluate("new TextDecoder().decode(new Uint8Array([0xAC])).charCodeAt(0)").AsNumber().Should().Be(0xFFFD); + first.Evaluate("d.decode(new Uint8Array([0xAC]))").AsString().Should().Be("€"); + } +} +#endif diff --git a/Jint.Tests/Runtime/WebApi/Base64Tests.cs b/Jint.Tests/Runtime/WebApi/Base64Tests.cs new file mode 100644 index 0000000000..94f10580fb --- /dev/null +++ b/Jint.Tests/Runtime/WebApi/Base64Tests.cs @@ -0,0 +1,198 @@ +#if NET8_0_OR_GREATER +#nullable enable + +using Jint.Runtime; + +namespace Jint.Tests.Runtime.WebApi; + +/// +/// atob and btoa as HTML specifies them — +/// https://html.spec.whatwg.org/multipage/webappapis.html#atob — over Infra's forgiving-base64 +/// algorithms, https://infra.spec.whatwg.org/#forgiving-base64. +/// +public class Base64Tests +{ + private static Engine WebEngine() => new(options => options.UseWebApis(WebApiFeatures.Base64)); + + [Theory] + [InlineData("", "")] + [InlineData("f", "Zg==")] + [InlineData("fo", "Zm8=")] + [InlineData("foo", "Zm9v")] + [InlineData("foob", "Zm9vYg==")] + [InlineData("fooba", "Zm9vYmE=")] + [InlineData("foobar", "Zm9vYmFy")] + [InlineData("hello world", "aGVsbG8gd29ybGQ=")] + public void BtoaEncodesTheRfc4648Vectors(string input, string expected) + { + var engine = WebEngine(); + + engine.SetValue("input", input); + engine.Evaluate("btoa(input)").AsString().Should().Be(expected); + } + + [Fact] + public void BtoaTakesOneByteFromEachCodeUnit() + { + var engine = WebEngine(); + + // U+00FF is the highest code unit it accepts, and it becomes the byte 0xFF. + engine.Evaluate("btoa('\\u00ff')").AsString().Should().Be("/w=="); + engine.Evaluate("btoa('\\u0000\\u0001\\u00fe\\u00ff')").AsString().Should().Be("AAH+/w=="); + } + + [Theory] + [InlineData("\\u0100")] + [InlineData("a\\u0100b")] + [InlineData("€")] + // btoa takes a DOMString, not a USVString, so a lone surrogate is not replaced by U+FFFD first — it + // is simply a code unit above U+00FF. + [InlineData("\\ud800")] + [InlineData("😀")] + public void BtoaRefusesACodeUnitAboveLatin1(string input) + { + var engine = WebEngine(); + + var exception = Assert.Throws(() => engine.Evaluate($"btoa('{input}')")); + + exception.Error.Get("name").AsString().Should().Be("InvalidCharacterError"); + exception.Error.Get("code").AsNumber().Should().Be(5); + engine.SetValue("thrown", exception.Error); + engine.Evaluate("thrown instanceof DOMException").AsBoolean().Should().BeTrue(); + } + + [Theory] + [InlineData("", "")] + [InlineData("Zg==", "f")] + [InlineData("Zm8=", "fo")] + [InlineData("Zm9v", "foo")] + [InlineData("aGVsbG8gd29ybGQ=", "hello world")] + // Unpadded input is accepted: this is where "forgiving" starts. + [InlineData("Zg", "f")] + [InlineData("Zm8", "fo")] + [InlineData("Zm9vYg", "foob")] + public void AtobDecodes(string input, string expected) + { + var engine = WebEngine(); + + engine.SetValue("input", input); + engine.Evaluate("atob(input)").AsString().Should().Be(expected); + } + + [Theory] + // Step 1 removes ASCII whitespace from anywhere, not just the ends. + [InlineData(" Zm9v ")] + [InlineData("Zm\n9v")] + [InlineData("Z m 9 v")] + [InlineData("\tZ\rm\f9v")] + [InlineData("Zm9v\n")] + public void AtobStripsAsciiWhitespaceAnywhere(string input) + { + var engine = WebEngine(); + + engine.SetValue("input", input); + engine.Evaluate("atob(input)").AsString().Should().Be("foo"); + } + + [Theory] + // A code point outside the alphabet. + [InlineData("a*bc")] + [InlineData("ab-c")] + [InlineData("ab_c")] + [InlineData("Zm9v!")] + // U+000B VT is not ASCII whitespace, so it is a stray code point rather than something to strip. + [InlineData("Zm\u000B9v")] + // Step 3: a length that leaves a remainder of one. + [InlineData("a")] + [InlineData("abcde")] + // Padding is only removed when the length already divides by four, so these keep their '=' and then + // fail the alphabet check. + [InlineData("Zg=")] + [InlineData("Zg===")] + [InlineData("=")] + [InlineData("==")] + [InlineData("====")] + [InlineData("Zm9vYg====")] + // '=' in the middle is never removed. + [InlineData("Z=9v")] + public void AtobRefusesWhatForgivingBase64CallsFailure(string input) + { + var engine = WebEngine(); + + engine.SetValue("input", input); + var exception = Assert.Throws(() => engine.Evaluate("atob(input)")); + + exception.Error.Get("name").AsString().Should().Be("InvalidCharacterError"); + exception.Error.Get("code").AsNumber().Should().Be(5); + } + + [Fact] + public void AtobRemovesAtMostTwoPaddingCodePoints() + { + var engine = WebEngine(); + + // Four '=' is a length of four, so two are removed and the remaining two are stray code points. + Assert.Throws(() => engine.Evaluate("atob('====')")); + + // Eight characters ending in two '=' is fine ... + engine.Evaluate("atob('Zm9vYg==')").AsString().Should().Be("foob"); + + // ... and the padding may itself be split by whitespace, since step 1 runs first. + engine.Evaluate("atob('Zm9vYg= =')").AsString().Should().Be("foob"); + } + + [Fact] + public void AtobProducesAByteString() + { + var engine = WebEngine(); + + // Every code unit of the result is a byte, U+0000 to U+00FF. + engine.Evaluate(""" + const decoded = atob('AAH+/w=='); + [decoded.length, decoded.charCodeAt(0), decoded.charCodeAt(1), decoded.charCodeAt(2), decoded.charCodeAt(3)].join(','); + """).AsString().Should().Be("4,0,1,254,255"); + } + + [Fact] + public void RoundTripsEveryByteValue() + { + var engine = WebEngine(); + + engine.Evaluate(""" + let all = ''; + for (let i = 0; i < 256; i++) { all += String.fromCharCode(i); } + atob(btoa(all)) === all; + """).AsBoolean().Should().BeTrue(); + } + + [Fact] + public void CoercesItsArgumentToAString() + { + var engine = WebEngine(); + + engine.Evaluate("btoa(123)").AsString().Should().Be("MTIz"); + engine.Evaluate("btoa(null)").AsString().Should().Be("bnVsbA=="); + engine.Evaluate("btoa({ toString() { return 'foo'; } })").AsString().Should().Be("Zm9v"); + engine.Evaluate("atob({ toString() { return 'Zm9v'; } })").AsString().Should().Be("foo"); + + // "undefined" is nine code points, and step 3 refuses a length that leaves a remainder of one. + Assert.Throws(() => engine.Evaluate("atob(undefined)")); + } + + [Fact] + public void AreOrdinaryGlobalFunctions() + { + var engine = WebEngine(); + + engine.Evaluate("typeof atob").AsString().Should().Be("function"); + engine.Evaluate("typeof btoa").AsString().Should().Be("function"); + engine.Evaluate("atob.length").AsNumber().Should().Be(1); + engine.Evaluate("btoa.length").AsNumber().Should().Be(1); + engine.Evaluate("atob.name").AsString().Should().Be("atob"); + engine.Evaluate("btoa.name").AsString().Should().Be("btoa"); + + // They are not constructors. + Assert.Throws(() => engine.Evaluate("new btoa('a')")); + } +} +#endif diff --git a/Jint.Tests/Runtime/WebApi/EncodingTests.cs b/Jint.Tests/Runtime/WebApi/EncodingTests.cs new file mode 100644 index 0000000000..63216bb2d7 --- /dev/null +++ b/Jint.Tests/Runtime/WebApi/EncodingTests.cs @@ -0,0 +1,567 @@ +#if NET8_0_OR_GREATER +#nullable enable + +using Jint.Runtime; + +namespace Jint.Tests.Runtime.WebApi; + +/// +/// TextEncoder and TextDecoder as the Encoding Standard specifies them — +/// https://encoding.spec.whatwg.org/#api. +/// +public class EncodingTests +{ + private static Engine WebEngine() => new(options => options.UseWebApis(WebApiFeatures.Encoding)); + + #region TextEncoder + + [Fact] + public void TextEncoderIsUtf8AndNothingElse() + { + var engine = WebEngine(); + + // "The encoding is always utf-8" — https://encoding.spec.whatwg.org/#dom-textencoder-encoding. + engine.Evaluate("new TextEncoder().encoding").AsString().Should().Be("utf-8"); + engine.Evaluate("TextEncoder.length").AsNumber().Should().Be(0); + engine.Evaluate("TextEncoder.name").AsString().Should().Be("TextEncoder"); + } + + [Fact] + public void EncodeDefaultsToTheEmptyString() + { + var engine = WebEngine(); + + engine.Evaluate("new TextEncoder().encode().length").AsNumber().Should().Be(0); + engine.Evaluate("new TextEncoder().encode(undefined).length").AsNumber().Should().Be(0); + engine.Evaluate("new TextEncoder().encode('') instanceof Uint8Array").AsBoolean().Should().BeTrue(); + } + + [Theory] + [InlineData("hi", "104,105")] + [InlineData("é", "195,169")] + [InlineData("€", "226,130,172")] + [InlineData("😀", "240,159,152,128")] + public void EncodeProducesUtf8(string input, string expected) + { + var engine = WebEngine(); + + engine.SetValue("input", input); + engine.Evaluate("new TextEncoder().encode(input).join(',')").AsString().Should().Be(expected); + } + + [Fact] + public void EncodeReplacesAnUnpairedSurrogate() + { + var engine = WebEngine(); + + // The argument is a USVString, so a lone surrogate becomes U+FFFD before it is ever encoded. + engine.Evaluate("new TextEncoder().encode('\\uD800').join(',')").AsString().Should().Be("239,191,189"); + engine.Evaluate("new TextEncoder().encode('\\uDC00').join(',')").AsString().Should().Be("239,191,189"); + engine.Evaluate("new TextEncoder().encode('a\\uD800b').join(',')").AsString().Should().Be("97,239,191,189,98"); + } + + [Fact] + public void EncodeIntoReportsCodeUnitsReadAndBytesWritten() + { + var engine = WebEngine(); + + var result = engine.Evaluate(""" + const r = new TextEncoder().encodeInto('hi€', new Uint8Array(16)); + [r.read, r.written].join(','); + """); + + // Three code units in, five bytes out. + result.AsString().Should().Be("3,5"); + } + + [Fact] + public void EncodeIntoCountsASurrogatePairAsTwoCodeUnits() + { + var engine = WebEngine(); + + engine.Evaluate(""" + const pair = new TextEncoder().encodeInto('😀', new Uint8Array(8)); + [pair.read, pair.written].join(','); + """).AsString().Should().Be("2,4"); + + // ... and the U+FFFD an unpaired surrogate becomes as one, since it is one code unit. + engine.Evaluate(""" + const lone = new TextEncoder().encodeInto('\uD800', new Uint8Array(8)); + [lone.read, lone.written].join(','); + """).AsString().Should().Be("1,3"); + } + + [Fact] + public void EncodeIntoNeverSplitsACodePoint() + { + var engine = WebEngine(); + + // Three bytes are needed for '€' and only two are left, so nothing at all is written for it. + engine.Evaluate(""" + const tooSmall = new Uint8Array(3); + const partial = new TextEncoder().encodeInto('a€', tooSmall); + [partial.read, partial.written, tooSmall.join(',')].join('|'); + """).AsString().Should().Be("1|1|97,0,0"); + + // With exactly enough room it fits. + engine.Evaluate(""" + const exact = new Uint8Array(4); + const full = new TextEncoder().encodeInto('a€', exact); + [full.read, full.written, exact.join(',')].join('|'); + """).AsString().Should().Be("2|4|97,226,130,172"); + } + + [Fact] + public void EncodeIntoWritesThroughAViewsOwnWindow() + { + var engine = WebEngine(); + + engine.Evaluate(""" + const buffer = new Uint8Array(6); + const window = buffer.subarray(2, 5); + const r = new TextEncoder().encodeInto('abcd', window); + [r.read, r.written, buffer.join(',')].join('|'); + """).AsString().Should().Be("3|3|0,0,97,98,99,0"); + } + + [Fact] + public void EncodeIntoRefusesADestinationThatIsNotAUint8Array() + { + var engine = WebEngine(); + + foreach (var destination in new[] { "new Uint16Array(8)", "new Uint8ClampedArray(8)", "new ArrayBuffer(8)", "[]", "undefined" }) + { + Assert.Throws(() => engine.Evaluate($"new TextEncoder().encodeInto('a', {destination})")) + .Error.Get("name").AsString().Should().Be("TypeError"); + } + } + + [Fact] + public void TextEncoderRequiresNewAndBrandChecksItsReceiver() + { + var engine = WebEngine(); + + Assert.Throws(() => engine.Evaluate("TextEncoder()")); + Assert.Throws(() => engine.Evaluate("TextEncoder.prototype.encode.call({})")); + Assert.Throws(() => engine.Evaluate("Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encoding').get.call({})")); + + // The prototype is not itself a TextEncoder, which is what a brand check means. + Assert.Throws(() => engine.Evaluate("TextEncoder.prototype.encoding")); + } + + #endregion + + #region TextDecoder labels + + [Fact] + public void TextDecoderDefaultsToUtf8() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder().encoding").AsString().Should().Be("utf-8"); + engine.Evaluate("new TextDecoder().fatal").AsBoolean().Should().BeFalse(); + engine.Evaluate("new TextDecoder().ignoreBOM").AsBoolean().Should().BeFalse(); + engine.Evaluate("new TextDecoder(undefined, undefined).encoding").AsString().Should().Be("utf-8"); + } + + [Theory] + // https://encoding.spec.whatwg.org/#names-and-labels, the UTF-8 row. + [InlineData("utf-8", "utf-8")] + [InlineData("utf8", "utf-8")] + [InlineData("UTF-8", "utf-8")] + [InlineData("unicode-1-1-utf-8", "utf-8")] + [InlineData("unicode11utf8", "utf-8")] + [InlineData("unicode20utf8", "utf-8")] + [InlineData("x-unicode20utf8", "utf-8")] + // The UTF-16LE row — note that a bare "utf-16" is little-endian. + [InlineData("utf-16", "utf-16le")] + [InlineData("utf-16le", "utf-16le")] + [InlineData("UTF-16LE", "utf-16le")] + [InlineData("unicode", "utf-16le")] + [InlineData("unicodefeff", "utf-16le")] + [InlineData("csunicode", "utf-16le")] + [InlineData("iso-10646-ucs-2", "utf-16le")] + [InlineData("ucs-2", "utf-16le")] + // The UTF-16BE row. + [InlineData("utf-16be", "utf-16be")] + [InlineData("unicodefffe", "utf-16be")] + // "Get an encoding" strips leading and trailing ASCII whitespace first. + [InlineData(" utf-8\t\n", "utf-8")] + [InlineData("\r\futf8 ", "utf-8")] + public void ResolvesALabelToItsEncodingName(string label, string expected) + { + var engine = WebEngine(); + + engine.SetValue("label", label); + engine.Evaluate("new TextDecoder(label).encoding").AsString().Should().Be(expected); + } + + [Theory] + [InlineData("windows-1252")] + [InlineData("iso-8859-1")] + [InlineData("shift_jis")] + [InlineData("replacement")] + [InlineData("x-user-defined")] + [InlineData("")] + [InlineData("utf-9")] + // ASCII case-insensitive matching and nothing more: U+017F must not fold onto 's'. + [InlineData("cſunicode")] + // U+000B VT is not ASCII whitespace, so it is not stripped. + [InlineData("\u000Butf-8")] + public void RefusesALabelOutsideTheImplementedSubset(string label) + { + var engine = WebEngine(); + + engine.SetValue("label", label); + Assert.Throws(() => engine.Evaluate("new TextDecoder(label)")) + .Error.Get("name").AsString().Should().Be("RangeError"); + } + + [Fact] + public void ReadsTheOptionsDictionary() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder('utf-8', { fatal: true }).fatal").AsBoolean().Should().BeTrue(); + engine.Evaluate("new TextDecoder('utf-8', { ignoreBOM: true }).ignoreBOM").AsBoolean().Should().BeTrue(); + + // Dictionary members are coerced with ToBoolean, and an absent one takes its default. + engine.Evaluate("new TextDecoder('utf-8', { fatal: 1 }).fatal").AsBoolean().Should().BeTrue(); + engine.Evaluate("new TextDecoder('utf-8', { fatal: undefined }).fatal").AsBoolean().Should().BeFalse(); + engine.Evaluate("new TextDecoder('utf-8', null).fatal").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void ConvertsBothArgumentsBeforeTheLabelIsLookedUp() + { + var engine = WebEngine(); + + // WebIDL converts the arguments and only then runs the constructor steps, so the getter runs even + // though the label is about to be refused. + var result = engine.Evaluate(""" + let seen = false; + try { + new TextDecoder('nonsense', { get fatal() { seen = true; return false; } }); + } catch (e) { + seen + ':' + e.name; + } + """); + + result.AsString().Should().Be("true:RangeError"); + } + + #endregion + + #region TextDecoder decoding + + [Fact] + public void DecodesEveryBufferSourceShape() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder().decode(new Uint8Array([104, 105]))").AsString().Should().Be("hi"); + engine.Evaluate("new TextDecoder().decode(new Uint8Array([104, 105]).buffer)").AsString().Should().Be("hi"); + engine.Evaluate("new TextDecoder().decode(new DataView(new Uint8Array([104, 105]).buffer))").AsString().Should().Be("hi"); + + // A view's own window, not the whole buffer. + engine.Evaluate("new TextDecoder().decode(new Uint8Array([104, 105, 106, 107]).subarray(1, 3))").AsString().Should().Be("ij"); + engine.Evaluate("new TextDecoder().decode(new DataView(new Uint8Array([104, 105, 106, 107]).buffer, 2))").AsString().Should().Be("jk"); + } + + [Fact] + public void DecodesNothingWhenGivenNothing() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder().decode()").AsString().Should().Be(""); + engine.Evaluate("new TextDecoder().decode(undefined)").AsString().Should().Be(""); + engine.Evaluate("new TextDecoder().decode(new Uint8Array(0))").AsString().Should().Be(""); + } + + [Fact] + public void RefusesAnInputThatIsNotABufferSource() + { + var engine = WebEngine(); + + // The IDL type is not nullable, so null is not the same as omitting the argument. + foreach (var input in new[] { "null", "'abc'", "42", "[]", "{}" }) + { + Assert.Throws(() => engine.Evaluate($"new TextDecoder().decode({input})")) + .Error.Get("name").AsString().Should().Be("TypeError"); + } + } + + [Fact] + public void ReplacesInvalidUtf8WithU00FFFD() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder().decode(new Uint8Array([0xFF])).charCodeAt(0)").AsNumber().Should().Be(0xFFFD); + engine.Evaluate("new TextDecoder().decode(new Uint8Array([0x61, 0xC0, 0x62]))").AsString().Should().Be("a\uFFFDb"); + } + + [Fact] + public void FatalTurnsInvalidInputIntoATypeError() + { + var engine = WebEngine(); + + Assert.Throws(() => engine.Evaluate("new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array([0xFF]))")) + .Error.Get("name").AsString().Should().Be("TypeError"); + + // An incomplete sequence at the end of a non-streaming decode is invalid too, because the decode + // flushes. + Assert.Throws(() => engine.Evaluate("new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array([0xE2, 0x82]))")) + .Error.Get("name").AsString().Should().Be("TypeError"); + + // ... and a decoder that threw is usable again. + engine.Evaluate(""" + const decoder = new TextDecoder('utf-8', { fatal: true }); + try { decoder.decode(new Uint8Array([0xFF])); } catch (e) { } + decoder.decode(new Uint8Array([104, 105])); + """).AsString().Should().Be("hi"); + } + + [Fact] + public void DecodesUtf16InBothEndiannesses() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0x68, 0x00, 0x69, 0x00]))").AsString().Should().Be("hi"); + engine.Evaluate("new TextDecoder('utf-16be').decode(new Uint8Array([0x00, 0x68, 0x00, 0x69]))").AsString().Should().Be("hi"); + + // A surrogate pair, so the code unit order matters. + engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0x3D, 0xD8, 0x00, 0xDE]))").AsString().Should().Be("😀"); + engine.Evaluate("new TextDecoder('utf-16be').decode(new Uint8Array([0xD8, 0x3D, 0xDE, 0x00]))").AsString().Should().Be("😀"); + } + + [Fact] + public void ReplacesAnUnpairedUtf16Surrogate() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0x00, 0xD8, 0x61, 0x00])).charCodeAt(0)") + .AsNumber().Should().Be(0xFFFD); + } + + #endregion + + #region Streaming + + [Fact] + public void StreamsACodePointSplitAcrossTwoChunks() + { + var engine = WebEngine(); + + // '€' is E2 82 AC; the first chunk ends mid-sequence and must produce nothing. + engine.Evaluate(""" + const decoder = new TextDecoder(); + const first = decoder.decode(new Uint8Array([0x61, 0xE2, 0x82]), { stream: true }); + const second = decoder.decode(new Uint8Array([0xAC, 0x62])); + [first, second].join('|'); + """).AsString().Should().Be("a|€b"); + } + + [Fact] + public void ANonStreamingDecodeEndsTheStream() + { + var engine = WebEngine(); + + // The held-over bytes of the first chunk are flushed as U+FFFD by the next non-streaming call, and + // the call after that starts a fresh stream rather than continuing. + engine.Evaluate(""" + const decoder = new TextDecoder(); + decoder.decode(new Uint8Array([0xE2, 0x82]), { stream: true }); + const flushed = decoder.decode(); + const fresh = decoder.decode(new Uint8Array([0xAC])); + [flushed.charCodeAt(0), fresh.charCodeAt(0)].join(','); + """).AsString().Should().Be("65533,65533"); + } + + [Fact] + public void StreamsAUtf16CodeUnitSplitAcrossTwoChunks() + { + var engine = WebEngine(); + + engine.Evaluate(""" + const decoder = new TextDecoder('utf-16le'); + const first = decoder.decode(new Uint8Array([0x68]), { stream: true }); + const second = decoder.decode(new Uint8Array([0x00, 0x69, 0x00])); + [first, second].join('|'); + """).AsString().Should().Be("|hi"); + } + + [Fact] + public void FatalDoesNotFireOnAnIncompleteSequenceMidStream() + { + var engine = WebEngine(); + + engine.Evaluate(""" + const decoder = new TextDecoder('utf-8', { fatal: true }); + decoder.decode(new Uint8Array([0xE2, 0x82]), { stream: true }); + decoder.decode(new Uint8Array([0xAC])); + """).AsString().Should().Be("€"); + } + + #endregion + + #region Byte order mark + + [Fact] + public void StripsOneLeadingBomPerStream() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder().decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61]))").AsString().Should().Be("a"); + + // Only the first one: a second U+FEFF is content. + engine.Evaluate("new TextDecoder().decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0xEF, 0xBB, 0xBF])).charCodeAt(0)") + .AsNumber().Should().Be(0xFEFF); + + // And only when it leads. + engine.Evaluate("new TextDecoder().decode(new Uint8Array([0x61, 0xEF, 0xBB, 0xBF])).length").AsNumber().Should().Be(2); + } + + [Fact] + public void KeepsTheBomWhenIgnoreBomIsSet() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder('utf-8', { ignoreBOM: true }).decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61])).length") + .AsNumber().Should().Be(2); + engine.Evaluate("new TextDecoder('utf-8', { ignoreBOM: true }).decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61])).charCodeAt(0)") + .AsNumber().Should().Be(0xFEFF); + } + + [Fact] + public void StripsABomSplitAcrossChunks() + { + var engine = WebEngine(); + + // The BOM is dropped by the serialize step, which sees scalar values rather than bytes, so it does + // not matter that its three bytes arrived in two pieces. + engine.Evaluate(""" + const decoder = new TextDecoder(); + const first = decoder.decode(new Uint8Array([0xEF, 0xBB]), { stream: true }); + const second = decoder.decode(new Uint8Array([0xBF, 0x61])); + [first, second].join('|'); + """).AsString().Should().Be("|a"); + } + + [Fact] + public void StripsTheBomOnlyOnceAcrossAWholeStream() + { + var engine = WebEngine(); + + engine.Evaluate(""" + const decoder = new TextDecoder(); + const first = decoder.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61]), { stream: true }); + const second = decoder.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x62])); + [first, second.charCodeAt(0), second.length].join('|'); + """).AsString().Should().Be("a|65279|2"); + } + + [Fact] + public void StripsTheUtf16Bom() + { + var engine = WebEngine(); + + engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0xFF, 0xFE, 0x61, 0x00]))").AsString().Should().Be("a"); + engine.Evaluate("new TextDecoder('utf-16be').decode(new Uint8Array([0xFE, 0xFF, 0x00, 0x61]))").AsString().Should().Be("a"); + } + + #endregion + + #region Shape + + [Fact] + public void TextDecoderBrandChecksItsReceiver() + { + var engine = WebEngine(); + + Assert.Throws(() => engine.Evaluate("TextDecoder()")); + Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.decode.call({})")); + Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.encoding")); + Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.fatal")); + Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.ignoreBOM")); + } + + [Fact] + public void HasTheWebIdlShape() + { + var engine = WebEngine(); + + engine.Evaluate("new TextEncoder() instanceof TextEncoder").AsBoolean().Should().BeTrue(); + engine.Evaluate("new TextDecoder() instanceof TextDecoder").AsBoolean().Should().BeTrue(); + engine.Evaluate("Object.getPrototypeOf(TextEncoder.prototype) === Object.prototype").AsBoolean().Should().BeTrue(); + engine.Evaluate("TextEncoder.prototype.constructor === TextEncoder").AsBoolean().Should().BeTrue(); + engine.Evaluate("TextDecoder.prototype.constructor === TextDecoder").AsBoolean().Should().BeTrue(); + engine.Evaluate("Object.prototype.toString.call(new TextEncoder())").AsString().Should().Be("[object TextEncoder]"); + engine.Evaluate("Object.prototype.toString.call(new TextDecoder())").AsString().Should().Be("[object TextDecoder]"); + + // An instance has no own property: everything is an accessor or an operation on the prototype. + engine.Evaluate("Object.getOwnPropertyNames(new TextEncoder()).length").AsNumber().Should().Be(0); + engine.Evaluate("Object.getOwnPropertyNames(new TextDecoder()).length").AsNumber().Should().Be(0); + } + + [Fact] + public void HasTheArityTheIdlDeclares() + { + var engine = WebEngine(); + + // Optional arguments do not count, so only encodeInto's two required ones do. + engine.Evaluate("TextEncoder.prototype.encode.length").AsNumber().Should().Be(0); + engine.Evaluate("TextEncoder.prototype.encodeInto.length").AsNumber().Should().Be(2); + engine.Evaluate("TextDecoder.prototype.decode.length").AsNumber().Should().Be(0); + engine.Evaluate("TextDecoder.length").AsNumber().Should().Be(0); + } + + [Fact] + public void ExposesAttributesAsEnumerableAccessorsAndOperationsAsNonEnumerable() + { + var engine = WebEngine(); + + // WebIDL attributes are enumerable accessor properties on the prototype. + engine.Evaluate(""" + const d = Object.getOwnPropertyDescriptor(TextDecoder.prototype, 'encoding'); + [typeof d.get, typeof d.set, d.enumerable, d.configurable].join('|'); + """).AsString().Should().Be("function|undefined|true|true"); + + // The operations are non-enumerable, which is Jint's one documented simplification here: WebIDL + // makes them enumerable, and the source generator gives every built-in method the ECMAScript + // attributes instead. + engine.Evaluate("Object.getOwnPropertyDescriptor(TextDecoder.prototype, 'decode').enumerable").AsBoolean().Should().BeFalse(); + engine.Evaluate("Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode').writable").AsBoolean().Should().BeTrue(); + + // @@toStringTag is configurable only, as the specification gives it. + engine.Evaluate(""" + const t = Object.getOwnPropertyDescriptor(TextEncoder.prototype, Symbol.toStringTag); + [t.value, t.writable, t.enumerable, t.configurable].join('|'); + """).AsString().Should().Be("TextEncoder|false|false|true"); + } + + [Fact] + public void SubclassingKeepsThePrototypeTheSubclassAsksFor() + { + var engine = WebEngine(); + + engine.Evaluate(""" + class MyEncoder extends TextEncoder { } + const e = new MyEncoder(); + [e instanceof MyEncoder, e instanceof TextEncoder, e.encoding].join('|'); + """).AsString().Should().Be("true|true|utf-8"); + } + + [Fact] + public void RoundTripsThroughEncodeAndDecode() + { + var engine = WebEngine(); + + engine.Evaluate(""" + const text = 'Grüße, 世界! 😀'; + new TextDecoder().decode(new TextEncoder().encode(text)) === text; + """).AsBoolean().Should().BeTrue(); + } + + #endregion +} +#endif diff --git a/Jint/Options.WebApi.cs b/Jint/Options.WebApi.cs index 2a3919ca0a..6e3624fa65 100644 --- a/Jint/Options.WebApi.cs +++ b/Jint/Options.WebApi.cs @@ -135,7 +135,6 @@ public class TimerOptions /// /// The bit layout is fixed ahead of the implementations so that a value persisted by a host keeps its meaning /// as the surface grows. The bits reserved for the features still to land are -/// Encoding = 1 << 2, Base64 = 1 << 3, /// StructuredClone = 1 << 4, Crypto = 1 << 5, Performance = 1 << 6, /// Events = 1 << 7, Url = 1 << 8, Files = 1 << 9 and /// Fetch = 1 << 10. A flag is declared here only once the feature behind it actually exists, so @@ -169,11 +168,23 @@ public enum WebApiFeatures /// Timers = 1 << 1, + /// + /// TextEncoder and TextDecoder — https://encoding.spec.whatwg.org/#api. The encoder is + /// UTF-8 only, as the standard requires; the decoder reads UTF-8, UTF-16LE and UTF-16BE. + /// + Encoding = 1 << 2, + + /// + /// The atob and btoa functions — + /// https://html.spec.whatwg.org/multipage/webappapis.html#atob. + /// + Base64 = 1 << 3, + /// /// The web APIs a host normally wants: everything except outbound network access. Today that is - /// and ; it grows as further features land, and never comes to - /// include fetch. + /// , , and ; it grows + /// as further features land, and never comes to include fetch. /// - Default = Console | Timers, + Default = Console | Timers | Encoding | Base64, } #endif diff --git a/Jint/Runtime/Intrinsics.WebApi.cs b/Jint/Runtime/Intrinsics.WebApi.cs index 62b873f171..1e807446a3 100644 --- a/Jint/Runtime/Intrinsics.WebApi.cs +++ b/Jint/Runtime/Intrinsics.WebApi.cs @@ -1,7 +1,9 @@ #if NET8_0_OR_GREATER +using Jint.WebApi.Base64; using Jint.WebApi.Console; using Jint.WebApi.DomException; using Jint.WebApi.Timers; +using Jint.WebApi.Encoding; namespace Jint.Runtime; @@ -20,6 +22,10 @@ public sealed partial class Intrinsics private DomExceptionConstructor? _domException; private ConsoleInstance? _console; private TimerFunctions? _timers; + private TextEncoderConstructor? _textEncoder; + private TextDecoderConstructor? _textDecoder; + private Base64Function? _atob; + private Base64Function? _btoa; internal DomExceptionConstructor DomException => _domException ??= new DomExceptionConstructor(_engine, _realm, Function.PrototypeObject, Error.PrototypeObject); @@ -33,5 +39,16 @@ public sealed partial class Intrinsics /// internal TimerFunctions Timers => _timers ??= TimerFunctions.Create(_engine, _realm); + internal TextEncoderConstructor TextEncoder => + _textEncoder ??= new TextEncoderConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject); + + internal TextDecoderConstructor TextDecoder => + _textDecoder ??= new TextDecoderConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject); + + internal Base64Function Atob => + _atob ??= new Base64Function(_engine, _realm, Function.PrototypeObject, isAtob: true); + + internal Base64Function Btoa => + _btoa ??= new Base64Function(_engine, _realm, Function.PrototypeObject, isAtob: false); } #endif diff --git a/Jint/WebApi/Base64/Base64Function.cs b/Jint/WebApi/Base64/Base64Function.cs new file mode 100644 index 0000000000..204b62ed38 --- /dev/null +++ b/Jint/WebApi/Base64/Base64Function.cs @@ -0,0 +1,110 @@ +#if NET8_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +using Jint.Native; +using Jint.Native.Function; +using Jint.Runtime; +using Jint.Runtime.Descriptors; +using Jint.WebApi.DomException; + +namespace Jint.WebApi.Base64; + +/// +/// The global atob and btoa functions. +/// +/// https://html.spec.whatwg.org/multipage/webappapis.html#atob +/// +/// +/// +/// +/// They are the two operations of HTML's WindowOrWorkerGlobalScope mixin that have nothing to do +/// with a window, which is why they belong on any global at all. One type serves both because the pair is +/// symmetric and each is a single call: two function objects per realm, built only if a script names one. +/// +/// +/// btoa(data) takes a DOMString, not a USVString: a lone surrogate is not silently +/// replaced by U+FFFD, it is a code point above U+00FF like any other and raises an +/// InvalidCharacterError. atob returns a ByteString, so every code unit of the result +/// is in the range U+0000 to U+00FF. +/// +/// +internal sealed class Base64Function : Native.Function.Function +{ + private static readonly JsString _atobName = new("atob"); + private static readonly JsString _btoaName = new("btoa"); + + private readonly bool _isAtob; + + internal Base64Function(Engine engine, Realm realm, FunctionPrototype functionPrototype, bool isAtob) + : base(engine, realm, isAtob ? _atobName : _btoaName) + { + _isAtob = isAtob; + _prototype = functionPrototype; + _length = new PropertyDescriptor(JsNumber.PositiveOne, PropertyFlag.Configurable); + } + + protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments) + { + var data = arguments.At(0); + return _isAtob ? Atob(data) : Btoa(data); + } + + /// + /// https://html.spec.whatwg.org/multipage/webappapis.html#dom-atob — "forgiving-base64 decode", and an + /// InvalidCharacterError for its failure. + /// + private JsString Atob(JsValue data) + { + var text = TypeConverter.ToString(data); + + if (!ForgivingBase64.TryDecode(text, out var bytes)) + { + ThrowInvalidCharacterError("The string to be decoded is not correctly encoded"); + return null!; + } + + if (bytes.Length == 0) + { + return JsString.Empty; + } + + // A ByteString: one code unit per byte, so Latin-1 is the encoding by definition rather than by + // choice. + return JsString.Create(System.Text.Encoding.Latin1.GetString(bytes)); + } + + /// + /// https://html.spec.whatwg.org/multipage/webappapis.html#dom-btoa — one byte per code unit, then + /// "forgiving-base64 encode", which is plain RFC 4648 base64. + /// + private JsString Btoa(JsValue data) + { + var text = TypeConverter.ToString(data); + + var bytes = new byte[text.Length]; + for (var i = 0; i < text.Length; i++) + { + var c = text[i]; + if (c > 0xFF) + { + ThrowInvalidCharacterError("The string to be encoded contains characters outside of the Latin1 range"); + } + + bytes[i] = (byte) c; + } + + return JsString.Create(Convert.ToBase64String(bytes)); + } + + /// + /// Raises the "InvalidCharacterError" DOMException both operations report their one + /// failure with. It is thrown as the error value, so script catches a real DOMException + /// and can read its name and code. + /// + [DoesNotReturn] + private void ThrowInvalidCharacterError(string message) + { + var error = _realm.Intrinsics.DomException.CreateException(DomExceptionNames.InvalidCharacter, message); + Throw.JavaScriptException(_engine, error, _engine.GetLastSyntaxElement()?.Location ?? default); + } +} +#endif diff --git a/Jint/WebApi/Base64/ForgivingBase64.cs b/Jint/WebApi/Base64/ForgivingBase64.cs new file mode 100644 index 0000000000..8591061d0b --- /dev/null +++ b/Jint/WebApi/Base64/ForgivingBase64.cs @@ -0,0 +1,167 @@ +#if NET8_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; + +namespace Jint.WebApi.Base64; + +/// +/// "Forgiving-base64 decode", https://infra.spec.whatwg.org/#forgiving-base64-decode — the algorithm +/// behind atob. +/// +/// +/// +/// It is deliberately hand-written rather than delegated to , which +/// is not the same function: Convert accepts only whitespace it happens to skip, insists on +/// padding to a multiple of four, and rejects several inputs the algorithm accepts — "YQ" and +/// "YWJj\ndA==" both decode here and both throw there. The differences run in the other direction +/// too, so neither "try Convert first" nor "clean the string up and hand it to Convert" is correct. +/// +/// +/// The padding rule is the subtle part and is exactly as written: trailing = is removed only +/// when the whitespace-stripped length is already a multiple of four, and at most two are removed. So +/// "YQ==" decodes, while "YQ=" (length 3) keeps its =, fails the alphabet check and +/// is a failure — which is what a browser does. +/// +/// +internal static class ForgivingBase64 +{ + /// + /// Decodes , or reports the specification's "failure", which atob turns + /// into an InvalidCharacterError. + /// + internal static bool TryDecode(string data, [NotNullWhen(true)] out byte[]? output) + { + output = null; + + // Step 1, and the part of steps 2 and 3 that needs the whitespace-stripped length. + var length = 0; + foreach (var c in data) + { + if (!IsAsciiWhitespace(c)) + { + length++; + } + } + + // Step 2: only a length that already divides by four sheds its padding, and never more than two + // code points of it. + if (length % 4 == 0) + { + var padding = 0; + for (var i = data.Length - 1; i >= 0 && padding < 2; i--) + { + var c = data[i]; + if (IsAsciiWhitespace(c)) + { + continue; + } + + if (c != '=') + { + break; + } + + padding++; + } + + length -= padding; + } + + // Step 3. + if (length % 4 == 1) + { + return false; + } + + var result = new byte[length / 4 * 3 + (length % 4) * 3 / 4]; + + var buffer = 0; + var bits = 0; + var written = 0; + var consumed = 0; + foreach (var c in data) + { + if (IsAsciiWhitespace(c)) + { + continue; + } + + if (consumed == length) + { + // Whatever is left can only be the one or two '=' step 2 removed: any other trailing code + // point would have left the length unchanged and been validated below. + break; + } + + consumed++; + + // Step 4, checked per code point as it is consumed rather than in a separate pass. + var sextet = Sextet(c); + if (sextet < 0) + { + return false; + } + + buffer = (buffer << 6) | sextet; + bits += 6; + if (bits == 24) + { + result[written++] = (byte) (buffer >> 16); + result[written++] = (byte) (buffer >> 8); + result[written++] = (byte) buffer; + buffer = 0; + bits = 0; + } + } + + // Step 9: 12 bits carry one byte (the last four are discarded), 18 bits carry two (the last two + // are). Six is impossible, because step 3 refused a length that leaves a remainder of one. + if (bits == 12) + { + result[written] = (byte) (buffer >> 4); + } + else if (bits == 18) + { + result[written++] = (byte) (buffer >> 10); + result[written] = (byte) (buffer >> 2); + } + + output = result; + return true; + } + + /// + /// The value of a code point in the base 64 alphabet of RFC 4648 table 1, or -1 for anything + /// outside it — U+002B (+), U+002F (/) and ASCII alphanumeric, which is step 4's list exactly. + /// + private static int Sextet(char c) + { + if (char.IsAsciiLetterUpper(c)) + { + return c - 'A'; + } + + if (char.IsAsciiLetterLower(c)) + { + return c - 'a' + 26; + } + + if (char.IsAsciiDigit(c)) + { + return c - '0' + 52; + } + + return c switch + { + '+' => 62, + '/' => 63, + _ => -1, + }; + } + + /// + /// https://infra.spec.whatwg.org/#ascii-whitespace — TAB, LF, FF, CR and SPACE. U+000B VT is not one, + /// so it is a decode failure rather than something to skip. + /// + private static bool IsAsciiWhitespace(char c) => c is '\t' or '\n' or '\f' or '\r' or ' '; +} +#endif diff --git a/Jint/WebApi/Encoding/BufferSource.cs b/Jint/WebApi/Encoding/BufferSource.cs new file mode 100644 index 0000000000..723789e8e5 --- /dev/null +++ b/Jint/WebApi/Encoding/BufferSource.cs @@ -0,0 +1,76 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.TypedArray; + +namespace Jint.WebApi.Encoding; + +/// +/// Reads the bytes behind a WebIDL AllowSharedBufferSource — an ArrayBuffer, a +/// SharedArrayBuffer, or any view over one (a typed array or a DataView). +/// +/// https://webidl.spec.whatwg.org/#AllowSharedBufferSource +/// +/// +/// +/// The span returned is a window onto the engine's own backing array, not a copy: the specification's +/// "get a copy of the buffer source" exists so that a later mutation cannot be observed, and every caller +/// here consumes the bytes synchronously before returning to script, so the copy would be pure cost. +/// +/// A detached buffer yields the empty byte sequence rather than an error, which is what +/// https://webidl.spec.whatwg.org/#dfn-get-buffer-source-copy step 7 says. A view left hanging outside +/// its resized buffer is clamped to what is still in range, which is the same answer with the same +/// reasoning — the bytes are gone, so there are none to hand over. +/// +/// +internal static class BufferSource +{ + /// + /// Yields the bytes of , or when it is not a buffer + /// source at all — which the caller reports as the TypeError the WebIDL conversion would raise. + /// + internal static bool TryGetBytes(JsValue value, out ReadOnlySpan bytes) + { + bytes = default; + + if (value is JsTypedArray typedArray) + { + // Length is the buffer-witness answer, so a length-tracking view follows a resize and an + // out-of-bounds one reports zero. + var elements = (int) typedArray.Length * typedArray._arrayElementType.GetElementSize(); + bytes = Window(typedArray._viewedArrayBuffer.ArrayBufferData, typedArray._byteOffset, elements); + return true; + } + + if (value is JsDataView dataView) + { + var data = dataView._viewedArrayBuffer?.ArrayBufferData; + var offset = (int) dataView._byteOffset; + var length = dataView._byteLength == JsTypedArray.LengthAuto + ? (data?.Length ?? 0) - offset + : (int) dataView._byteLength; + + bytes = Window(data, offset, length); + return true; + } + + // JsSharedArrayBuffer derives from JsArrayBuffer, which is what AllowShared means here. + if (value is JsArrayBuffer arrayBuffer) + { + bytes = Window(arrayBuffer.ArrayBufferData, 0, arrayBuffer.ArrayBufferByteLength); + return true; + } + + return false; + } + + private static ReadOnlySpan Window(byte[]? data, int offset, int length) + { + if (data is null || (uint) offset >= (uint) data.Length || length <= 0) + { + return default; + } + + return data.AsSpan(offset, Math.Min(length, data.Length - offset)); + } +} +#endif diff --git a/Jint/WebApi/Encoding/EncodingLabels.cs b/Jint/WebApi/Encoding/EncodingLabels.cs new file mode 100644 index 0000000000..74c1b882d9 --- /dev/null +++ b/Jint/WebApi/Encoding/EncodingLabels.cs @@ -0,0 +1,114 @@ +#if NET8_0_OR_GREATER +namespace Jint.WebApi.Encoding; + +/// +/// The encodings Jint's TextDecoder understands and the labels that name them. +/// +/// https://encoding.spec.whatwg.org/#names-and-labels +/// +/// +/// +/// +/// Three of the specification's encodings are implemented — utf-8, utf-16le and +/// utf-16be — with every label the table gives each of them. The legacy single-byte and +/// multi-byte encodings (windows-1252, shift_jis, gbk, …) are deliberately out of +/// scope: each needs its own index table, and a script reaching for one is not the case this surface +/// exists for. An unimplemented label is reported as a failure, so the constructor raises the +/// RangeError the specification asks for rather than silently decoding as something else. +/// +/// +/// The replacement, x-user-defined and UTF-16-BOM-sniffing behaviours that only +/// matter for those legacy encodings are out of scope with them. +/// +/// +internal static class EncodingLabels +{ + /// The name https://encoding.spec.whatwg.org/#utf-8 gives the encoding, already ASCII-lowercase. + internal const string Utf8 = "utf-8"; + + /// The name https://encoding.spec.whatwg.org/#utf-16le gives the encoding. + internal const string Utf16Le = "utf-16le"; + + /// The name https://encoding.spec.whatwg.org/#utf-16be gives the encoding. + internal const string Utf16Be = "utf-16be"; + + /// "unicode-1-1-utf-8", the longest label in the implemented subset. + private const int MaxLabelLength = 17; + + /// + /// ASCII whitespace, https://infra.spec.whatwg.org/#ascii-whitespace — note that U+000B VT is not one + /// of them, so a label padded with a vertical tab does not match. + /// + private static ReadOnlySpan AsciiWhitespace => ['\t', '\n', '\f', '\r', ' ']; + + /// + /// "Get an encoding", https://encoding.spec.whatwg.org/#concept-encoding-get: strip leading and + /// trailing ASCII whitespace, then match the rest ASCII case-insensitively against the label table. + /// Returns the encoding's name, or for the specification's "failure". + /// + /// + /// The match is ASCII case-insensitive and nothing more, which is why the comparison is spelled out + /// rather than handed to : that folds U+017F LATIN + /// SMALL LETTER LONG S onto s, so "cſunicode" would be accepted as a label for + /// utf-16le. Any non-ASCII code point in the trimmed label makes it fail here, since every + /// label in the table is ASCII. + /// + internal static string? Lookup(string label) + { + var trimmed = label.AsSpan().Trim(AsciiWhitespace); + if (trimmed.Length is 0 or > MaxLabelLength) + { + return null; + } + + Span lowered = stackalloc char[MaxLabelLength]; + for (var i = 0; i < trimmed.Length; i++) + { + var c = trimmed[i]; + if (!char.IsAscii(c)) + { + return null; + } + + lowered[i] = char.IsAsciiLetterUpper(c) ? (char) (c + ('a' - 'A')) : c; + } + + return Match(lowered.Slice(0, trimmed.Length)); + } + + private static string? Match(ReadOnlySpan label) + { + // https://encoding.spec.whatwg.org/#table-encoding-overrides, the UTF-8 row. + if (label.SequenceEqual("utf-8") + || label.SequenceEqual("utf8") + || label.SequenceEqual("unicode-1-1-utf-8") + || label.SequenceEqual("unicode11utf8") + || label.SequenceEqual("unicode20utf8") + || label.SequenceEqual("x-unicode20utf8")) + { + return Utf8; + } + + // The UTF-16LE row. "utf-16" without a suffix is little-endian, which is the one label here that + // regularly surprises people. + if (label.SequenceEqual("utf-16le") + || label.SequenceEqual("utf-16") + || label.SequenceEqual("unicode") + || label.SequenceEqual("unicodefeff") + || label.SequenceEqual("csunicode") + || label.SequenceEqual("iso-10646-ucs-2") + || label.SequenceEqual("ucs-2")) + { + return Utf16Le; + } + + // The UTF-16BE row. + if (label.SequenceEqual("utf-16be") || label.SequenceEqual("unicodefffe")) + { + return Utf16Be; + } + + return null; + } +} +#endif diff --git a/Jint/WebApi/Encoding/JsTextDecoder.cs b/Jint/WebApi/Encoding/JsTextDecoder.cs new file mode 100644 index 0000000000..002cdf0e21 --- /dev/null +++ b/Jint/WebApi/Encoding/JsTextDecoder.cs @@ -0,0 +1,143 @@ +#if NET8_0_OR_GREATER +using System.Text; +using Jint.Native; +using Jint.Native.Object; +using Jint.Runtime; +using SystemEncoding = System.Text.Encoding; + +namespace Jint.WebApi.Encoding; + +/// +/// A TextDecoder instance, and the decoding state it carries between calls. +/// +/// https://encoding.spec.whatwg.org/#interface-textdecoder +/// +/// +/// +/// +/// The specification's decoder state — the encoding's decoder instance, the I/O queue holding the bytes +/// of an incomplete sequence, the "do not flush" flag and the "BOM seen" flag — maps onto a BCL +/// plus two booleans. The is what holds the bytes of a +/// sequence split across two decode(…, { stream: true }) calls, which is why it is created once +/// per stream and kept until a non-streaming call ends it. +/// +/// +/// fatal is the BCL's and the default error mode is its +/// , so the U+FFFD substitution follows the Unicode +/// "maximal subpart" recommendation that https://encoding.spec.whatwg.org/#error-mode also describes. +/// +/// +internal sealed class JsTextDecoder : ObjectInstance +{ + // One encoding object per (encoding, error mode). They are immutable and thread-safe; only the + // Decoder they hand out is stateful, and that one is per instance. + private static readonly SystemEncoding _utf8Replacement = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false); + private static readonly SystemEncoding _utf8Fatal = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + private static readonly SystemEncoding _utf16LeReplacement = new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: false); + private static readonly SystemEncoding _utf16LeFatal = new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true); + private static readonly SystemEncoding _utf16BeReplacement = new UnicodeEncoding(bigEndian: true, byteOrderMark: false, throwOnInvalidBytes: false); + private static readonly SystemEncoding _utf16BeFatal = new UnicodeEncoding(bigEndian: true, byteOrderMark: false, throwOnInvalidBytes: true); + + /// U+FEFF, the code point "serialize I/O queue" drops when it leads the stream. + private const char ByteOrderMark = '\uFEFF'; + + private readonly SystemEncoding _encoding; + private readonly string _encodingName; + + private Decoder? _decoder; + private bool _doNotFlush; + private bool _bomSeen; + + internal JsTextDecoder(Engine engine, string encodingName, bool fatal, bool ignoreBom) : base(engine, ObjectClass.Object) + { + Name = JsString.Create(encodingName); + Fatal = fatal; + IgnoreBom = ignoreBom; + _encodingName = encodingName; + _encoding = Resolve(encodingName, fatal); + } + + /// The encoding's name, already ASCII-lowercase — https://encoding.spec.whatwg.org/#dom-textdecoder-encoding. + internal JsString Name { get; } + + /// https://encoding.spec.whatwg.org/#dom-textdecoder-fatal + internal bool Fatal { get; } + + /// https://encoding.spec.whatwg.org/#dom-textdecoder-ignorebom + internal bool IgnoreBom { get; } + + /// + /// https://encoding.spec.whatwg.org/#dom-textdecoder-decode, followed by "serialize I/O queue" + /// (https://encoding.spec.whatwg.org/#concept-td-serialize) for the BOM handling. + /// + /// The realm a fatal failure's TypeError belongs to. + /// The bytes to push onto the queue; empty when the argument was omitted. + /// The stream option, which becomes the new "do not flush" flag. + internal JsString Decode(Realm realm, ReadOnlySpan input, bool stream) + { + // Step 1: a decode that follows a non-streaming one starts a new stream, which is what discarding + // the decoder does — the next call builds a fresh one, holding no bytes over and having seen no BOM. + if (!_doNotFlush) + { + _decoder = null; + _bomSeen = false; + } + + _doNotFlush = stream; + + var decoder = _decoder ??= _encoding.GetDecoder(); + + char[] chars; + int charCount; + try + { + // GetCharCount does not advance the decoder, so this is the documented two-pass shape rather + // than a double decode. + chars = new char[decoder.GetCharCount(input, flush: !stream)]; + charCount = decoder.GetChars(input, chars, flush: !stream); + } + catch (DecoderFallbackException) + { + // The instance is reset rather than left holding a decoder whose state after a fallback + // exception the BCL does not define, so the next decode starts a clean stream. + _decoder = null; + _doNotFlush = false; + _bomSeen = false; + Throw.TypeError(realm, "The encoded data was not valid for encoding " + _encodingName); + return null!; + } + + var decoded = chars.AsSpan(0, charCount); + + // "Serialize I/O queue" step 2.3: for utf-8, utf-16le and utf-16be — which is all three we + // implement — the very first scalar value of the stream is dropped when it is U+FEFF. BOM seen is + // set by looking, not by finding one, so at most one leading BOM is ever removed and only once per + // stream, which is what makes a BOM split across two chunks work. + if (!IgnoreBom && !_bomSeen && decoded.Length > 0) + { + _bomSeen = true; + if (decoded[0] == ByteOrderMark) + { + decoded = decoded.Slice(1); + } + } + + return decoded.Length == 0 ? JsString.Empty : JsString.Create(decoded.ToString()); + } + + private static SystemEncoding Resolve(string encodingName, bool fatal) + { + if (string.Equals(encodingName, EncodingLabels.Utf16Le, StringComparison.Ordinal)) + { + return fatal ? _utf16LeFatal : _utf16LeReplacement; + } + + if (string.Equals(encodingName, EncodingLabels.Utf16Be, StringComparison.Ordinal)) + { + return fatal ? _utf16BeFatal : _utf16BeReplacement; + } + + return fatal ? _utf8Fatal : _utf8Replacement; + } +} +#endif diff --git a/Jint/WebApi/Encoding/JsTextEncoder.cs b/Jint/WebApi/Encoding/JsTextEncoder.cs new file mode 100644 index 0000000000..d7c575324c --- /dev/null +++ b/Jint/WebApi/Encoding/JsTextEncoder.cs @@ -0,0 +1,26 @@ +#if NET8_0_OR_GREATER +using Jint.Native.Object; +using Jint.Runtime; + +namespace Jint.WebApi.Encoding; + +/// +/// A TextEncoder instance. +/// +/// https://encoding.spec.whatwg.org/#interface-textencoder +/// +/// +/// +/// It carries no state at all: TextEncoder encodes UTF-8 and nothing else, and neither +/// encode nor encodeInto streams, so there is no pending code unit to remember between +/// calls. The type exists so the prototype has a brand to check — a plain object handed to +/// TextEncoder.prototype.encode must raise a TypeError, which it cannot if every object +/// looks like an encoder. +/// +internal sealed class JsTextEncoder : ObjectInstance +{ + internal JsTextEncoder(Engine engine) : base(engine, ObjectClass.Object) + { + } +} +#endif diff --git a/Jint/WebApi/Encoding/TextDecoderConstructor.cs b/Jint/WebApi/Encoding/TextDecoderConstructor.cs new file mode 100644 index 0000000000..ef210ea1cf --- /dev/null +++ b/Jint/WebApi/Encoding/TextDecoderConstructor.cs @@ -0,0 +1,93 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Function; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; + +namespace Jint.WebApi.Encoding; + +/// +/// The TextDecoder interface object. +/// +/// https://encoding.spec.whatwg.org/#interface-textdecoder +/// +/// +/// +/// +/// constructor(optional DOMString label = "utf-8", optional TextDecoderOptions options = {}). The +/// two arguments are converted first and the constructor steps run after, which is the order WebIDL +/// specifies and the reason a bad label raises its RangeError only once options has +/// been read — a getter on options runs even when the label is nonsense. +/// +/// +/// Called without new it raises a TypeError, which already does +/// for every interface object shape. +/// +/// +internal sealed class TextDecoderConstructor : Constructor +{ + private static readonly JsString _functionName = new("TextDecoder"); + private static readonly JsString _fatal = new("fatal"); + private static readonly JsString _ignoreBom = new("ignoreBOM"); + + internal TextDecoderConstructor( + Engine engine, + Realm realm, + FunctionPrototype functionPrototype, + ObjectPrototype objectPrototype) + : base(engine, realm, _functionName) + { + _prototype = functionPrototype; + PrototypeObject = new TextDecoderPrototype(engine, realm, this, objectPrototype); + _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable); + _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); + } + + internal TextDecoderPrototype PrototypeObject { get; } + + /// + /// https://encoding.spec.whatwg.org/#dom-textdecoder + /// + public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) + { + var labelArgument = arguments.At(0); + var optionsArgument = arguments.At(1); + + // `optional DOMString label = "utf-8"`: an omitted argument and an explicitly passed undefined + // both take the default. + var label = labelArgument.IsUndefined() ? EncodingLabels.Utf8 : TypeConverter.ToString(labelArgument); + + // The dictionary conversion, https://webidl.spec.whatwg.org/#es-dictionary: undefined and null + // are the empty dictionary, anything else that is not an object is a TypeError, and the members + // are read in lexicographic order — "fatal" before "ignoreBOM". + var fatal = false; + var ignoreBom = false; + if (!optionsArgument.IsUndefined() && !optionsArgument.IsNull()) + { + if (optionsArgument is not ObjectInstance options) + { + Throw.TypeError(_realm, "TextDecoder: options must be an object"); + return null!; + } + + fatal = TypeConverter.ToBoolean(options.Get(_fatal)); + ignoreBom = TypeConverter.ToBoolean(options.Get(_ignoreBom)); + } + + // Step 1 and 2: get an encoding from the label, and refuse anything the table does not name. + var encoding = EncodingLabels.Lookup(label); + if (encoding is null) + { + Throw.RangeError(_realm, "TextDecoder: the encoding label provided ('" + label + "') is invalid"); + } + + return OrdinaryCreateFromConstructor( + newTarget, + static intrinsics => intrinsics.TextDecoder.PrototypeObject, + static (Engine engine, Realm _, (string Encoding, bool Fatal, bool IgnoreBom) state) + => new JsTextDecoder(engine, state.Encoding, state.Fatal, state.IgnoreBom), + (Encoding: encoding!, Fatal: fatal, IgnoreBom: ignoreBom)); + } +} +#endif diff --git a/Jint/WebApi/Encoding/TextDecoderPrototype.cs b/Jint/WebApi/Encoding/TextDecoderPrototype.cs new file mode 100644 index 0000000000..9b4dbb081c --- /dev/null +++ b/Jint/WebApi/Encoding/TextDecoderPrototype.cs @@ -0,0 +1,135 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; + +namespace Jint.WebApi.Encoding; + +/// +/// TextDecoder.prototype — the interface prototype object. +/// +/// https://encoding.spec.whatwg.org/#interface-textdecoder +/// +/// +/// +/// +/// encoding, fatal and ignoreBOM are the TextDecoderCommon attributes. They +/// are accessors here rather than own properties of the instance, as WebIDL specifies attributes, and each +/// brand-checks its receiver — including TextDecoder.prototype itself, which is not a +/// TextDecoder. +/// +/// +/// One documented simplification against WebIDL, the same one console makes: decode is +/// non-enumerable, where https://webidl.spec.whatwg.org/#es-operations gives an interface prototype +/// object's operations { writable: true, enumerable: true, configurable: true }. The attributes +/// themselves are enumerable, as WebIDL asks. +/// +/// +[JsObject(UseShape = true)] +internal sealed partial class TextDecoderPrototype : Prototype +{ + [JsProperty(Name = "constructor", Flags = PropertyFlag.NonEnumerable)] + private readonly TextDecoderConstructor _constructor; + + [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)] + private static readonly JsString TextDecoderToStringTag = new("TextDecoder"); + + private static readonly JsString _stream = new("stream"); + + internal TextDecoderPrototype( + Engine engine, + Realm realm, + TextDecoderConstructor constructor, + ObjectPrototype objectPrototype) : base(engine, realm) + { + _prototype = objectPrototype; + _constructor = constructor; + } + + protected override void Initialize() + { + CreateProperties_Generated(); + CreateSymbols_Generated(); + } + + /// + /// https://encoding.spec.whatwg.org/#dom-textdecoder-encoding + /// + [JsAccessor("encoding", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString EncodingGet(JsValue thisObject) + { + return Brand(thisObject).Name; + } + + /// + /// https://encoding.spec.whatwg.org/#dom-textdecoder-fatal + /// + [JsAccessor("fatal", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsBoolean FatalGet(JsValue thisObject) + { + return Brand(thisObject).Fatal ? JsBoolean.True : JsBoolean.False; + } + + /// + /// https://encoding.spec.whatwg.org/#dom-textdecoder-ignorebom + /// + [JsAccessor("ignoreBOM", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsBoolean IgnoreBomGet(JsValue thisObject) + { + return Brand(thisObject).IgnoreBom ? JsBoolean.True : JsBoolean.False; + } + + /// + /// https://encoding.spec.whatwg.org/#dom-textdecoder-decode + /// + /// + /// input is an AllowSharedBufferSource: an ArrayBuffer, a SharedArrayBuffer + /// or any view over one. Omitting it decodes nothing, which is how a stream is flushed + /// (decode() with no argument ends the stream and emits whatever a trailing incomplete sequence + /// became). Passing null is not the same as omitting it — the IDL type is not nullable — so it + /// is a TypeError. + /// + [JsFunction(Name = "decode", Length = 0)] + private JsString Decode(JsValue thisObject, JsValue input, JsValue options) + { + var decoder = Brand(thisObject); + + var bytes = ReadOnlySpan.Empty; + if (!input.IsUndefined() && !BufferSource.TryGetBytes(input, out bytes)) + { + Throw.TypeError(_realm, "TextDecoder.decode: input must be an ArrayBuffer or a view over one"); + } + + // The TextDecodeOptions dictionary, read the same way TextDecoderOptions is in the constructor. + var stream = false; + if (!options.IsUndefined() && !options.IsNull()) + { + if (options is not ObjectInstance optionsObject) + { + Throw.TypeError(_realm, "TextDecoder.decode: options must be an object"); + return null!; + } + + stream = TypeConverter.ToBoolean(optionsObject.Get(_stream)); + } + + return decoder.Decode(_realm, bytes, stream); + } + + /// + /// The WebIDL brand check every operation and attribute performs before it converts its arguments: a + /// receiver that is not a platform object implementing the interface raises a TypeError. + /// + private JsTextDecoder Brand(JsValue thisObject) + { + if (thisObject is JsTextDecoder decoder) + { + return decoder; + } + + Throw.TypeError(_realm, "Illegal invocation: receiver is not a TextDecoder"); + return null!; + } +} +#endif diff --git a/Jint/WebApi/Encoding/TextEncoderConstructor.cs b/Jint/WebApi/Encoding/TextEncoderConstructor.cs new file mode 100644 index 0000000000..a8ca5def16 --- /dev/null +++ b/Jint/WebApi/Encoding/TextEncoderConstructor.cs @@ -0,0 +1,52 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Function; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; + +namespace Jint.WebApi.Encoding; + +/// +/// The TextEncoder interface object. +/// +/// https://encoding.spec.whatwg.org/#interface-textencoder +/// +/// +/// +/// constructor() — it takes no arguments, because the encoder is UTF-8 and only UTF-8: +/// "the encoding is always UTF-8", https://encoding.spec.whatwg.org/#dom-textencoder. Called without +/// new it raises a TypeError, which already does for every +/// interface object shape. +/// +internal sealed class TextEncoderConstructor : Constructor +{ + private static readonly JsString _functionName = new("TextEncoder"); + + internal TextEncoderConstructor( + Engine engine, + Realm realm, + FunctionPrototype functionPrototype, + ObjectPrototype objectPrototype) + : base(engine, realm, _functionName) + { + _prototype = functionPrototype; + PrototypeObject = new TextEncoderPrototype(engine, realm, this, objectPrototype); + _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable); + _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); + } + + internal TextEncoderPrototype PrototypeObject { get; } + + /// + /// https://encoding.spec.whatwg.org/#dom-textencoder + /// + public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) + { + return OrdinaryCreateFromConstructor( + newTarget, + static intrinsics => intrinsics.TextEncoder.PrototypeObject, + static (Engine engine, Realm _, object? _) => new JsTextEncoder(engine)); + } +} +#endif diff --git a/Jint/WebApi/Encoding/TextEncoderPrototype.cs b/Jint/WebApi/Encoding/TextEncoderPrototype.cs new file mode 100644 index 0000000000..1d1cd4c59d --- /dev/null +++ b/Jint/WebApi/Encoding/TextEncoderPrototype.cs @@ -0,0 +1,177 @@ +#if NET8_0_OR_GREATER +using System.Text; +using Jint.Native; +using Jint.Native.Object; +using Jint.Native.TypedArray; +using Jint.Runtime; +using Jint.Runtime.Descriptors; +using SystemEncoding = System.Text.Encoding; + +namespace Jint.WebApi.Encoding; + +/// +/// TextEncoder.prototype — the interface prototype object. +/// +/// https://encoding.spec.whatwg.org/#interface-textencoder +/// +/// +/// +/// +/// encoding is the TextEncoderCommon attribute and answers "utf-8" for every +/// instance, since that is the only encoding TextEncoder has ever supported. It is an accessor +/// here rather than an own property of the instance, as WebIDL specifies attributes, and brand-checks its +/// receiver — including TextEncoder.prototype itself, which is not a TextEncoder. +/// +/// +/// One documented simplification against WebIDL, the same one console makes: encode and +/// encodeInto are non-enumerable, where https://webidl.spec.whatwg.org/#es-operations gives an +/// interface prototype object's operations { writable: true, enumerable: true, configurable: true }. +/// The attributes themselves are enumerable, as WebIDL asks. +/// +/// +[JsObject(UseShape = true)] +internal sealed partial class TextEncoderPrototype : Prototype +{ + [JsProperty(Name = "constructor", Flags = PropertyFlag.NonEnumerable)] + private readonly TextEncoderConstructor _constructor; + + [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)] + private static readonly JsString TextEncoderToStringTag = new("TextEncoder"); + + private static readonly JsString _utf8 = new(EncodingLabels.Utf8); + + /// + /// TextEncoderEncodeIntoResult, https://encoding.spec.whatwg.org/#dictdef-textencoderencodeintoresult — + /// a WebIDL dictionary, so an ordinary object with two data properties in declaration order. Declaring + /// it as a layout means every result object in an engine shares one hidden class, so a loop reading + /// .written off them keeps a monomorphic inline cache. + /// + private static readonly JsObjectLayout _encodeIntoResultLayout = JsObjectLayout.CreateBuilder() + .Add("read") + .Add("written") + .Build(); + + internal TextEncoderPrototype( + Engine engine, + Realm realm, + TextEncoderConstructor constructor, + ObjectPrototype objectPrototype) : base(engine, realm) + { + _prototype = objectPrototype; + _constructor = constructor; + } + + protected override void Initialize() + { + CreateProperties_Generated(); + CreateSymbols_Generated(); + } + + /// + /// https://encoding.spec.whatwg.org/#dom-textencoder-encoding + /// + [JsAccessor("encoding", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString EncodingGet(JsValue thisObject) + { + Brand(thisObject); + return _utf8; + } + + /// + /// https://encoding.spec.whatwg.org/#dom-textencoder-encode + /// + /// + /// The argument is a USVString, so an unpaired surrogate is replaced by U+FFFD rather than + /// encoded — which is exactly what .NET's UTF-8 encoder does with its replacement fallback, so the + /// conversion and the encoding happen in one pass. + /// + [JsFunction(Name = "encode", Length = 0)] + private JsTypedArray Encode(JsValue thisObject, JsValue input) + { + Brand(thisObject); + + // `optional USVString input = ""`: an omitted argument and an explicitly passed undefined both + // take the default. + var text = input.IsUndefined() ? string.Empty : TypeConverter.ToString(input); + return _realm.Intrinsics.Uint8Array.Construct(SystemEncoding.UTF8.GetBytes(text)); + } + + /// + /// https://encoding.spec.whatwg.org/#dom-textencoder-encodeinto + /// + /// + /// The loop stops as soon as the next scalar value's UTF-8 sequence would not fit, so a code point is + /// never split across the end of the destination. read counts UTF-16 code units consumed, which + /// is why a surrogate pair advances it by two while the U+FFFD an unpaired surrogate becomes advances + /// it by one. + /// + [JsFunction(Name = "encodeInto", Length = 2)] + private JsObject EncodeInto(JsValue thisObject, JsValue source, JsValue destination) + { + Brand(thisObject); + + var text = TypeConverter.ToString(source); + + // `[AllowShared] Uint8Array destination` — a Uint8ClampedArray or any other view is a WebIDL + // conversion failure, not a silently accepted destination. + if (destination is not JsTypedArray { _arrayElementType: TypedArrayElementType.Uint8 } target) + { + Throw.TypeError(_realm, "TextEncoder.encodeInto: destination must be a Uint8Array"); + return null!; + } + + var buffer = target._viewedArrayBuffer; + buffer.AssertNotImmutable(); + + var available = Writable(buffer.ArrayBufferData, target._byteOffset, (int) target.Length); + + var read = 0; + var written = 0; + while (read < text.Length) + { + // Rune.DecodeFromUtf16 is the specification's "convert code unit to scalar value" exactly: + // an unpaired surrogate, leading or trailing, decodes to U+FFFD and consumes one code unit. + Rune.DecodeFromUtf16(text.AsSpan(read), out var rune, out var charsConsumed); + + var byteCount = rune.Utf8SequenceLength; + if (available.Length - written < byteCount) + { + break; + } + + rune.EncodeToUtf8(available.Slice(written)); + written += byteCount; + read += charsConsumed; + } + + return JsObject.Create(_engine, _encodeIntoResultLayout, [JsNumber.Create(read), JsNumber.Create(written)]); + } + + /// + /// The destination's own window onto its buffer. A detached buffer, and a view left outside a resized + /// one, both yield an empty span — which stops the loop before its first write, so the result is + /// { read: 0, written: 0 } rather than an exception. + /// + private static Span Writable(byte[]? data, int byteOffset, int length) + { + if (data is null || (uint) byteOffset >= (uint) data.Length || length <= 0) + { + return default; + } + + return data.AsSpan(byteOffset, Math.Min(length, data.Length - byteOffset)); + } + + /// + /// The WebIDL brand check every operation and attribute performs before it converts its arguments: a + /// receiver that is not a platform object implementing the interface raises a TypeError. + /// + private void Brand(JsValue thisObject) + { + if (thisObject is not JsTextEncoder) + { + Throw.TypeError(_realm, "Illegal invocation: receiver is not a TextEncoder"); + } + } +} +#endif diff --git a/Jint/WebApi/WebApiRegistration.cs b/Jint/WebApi/WebApiRegistration.cs index 5cae3a5151..ad164e9829 100644 --- a/Jint/WebApi/WebApiRegistration.cs +++ b/Jint/WebApi/WebApiRegistration.cs @@ -64,6 +64,20 @@ internal static void Apply(Options options, Engine engine) Install(global, engine, "clearInterval", static e => e.Realm.Intrinsics.Timers.ClearInterval, PropertyFlag.ConfigurableEnumerableWritable); Install(global, engine, "queueMicrotask", static e => e.Realm.Intrinsics.Timers.QueueMicrotask, PropertyFlag.ConfigurableEnumerableWritable); } + + if ((options.WebApi.Features & WebApiFeatures.Encoding) != WebApiFeatures.None) + { + Install(global, engine, "TextDecoder", static e => e.Realm.Intrinsics.TextDecoder, PropertyFlag.NonEnumerable); + Install(global, engine, "TextEncoder", static e => e.Realm.Intrinsics.TextEncoder, PropertyFlag.NonEnumerable); + } + + if ((options.WebApi.Features & WebApiFeatures.Base64) != WebApiFeatures.None) + { + // Operations of a WebIDL interface mixin on the global are enumerable, unlike interface + // objects — https://webidl.spec.whatwg.org/#es-operations. + Install(global, engine, "atob", static e => e.Realm.Intrinsics.Atob, PropertyFlag.ConfigurableEnumerableWritable); + Install(global, engine, "btoa", static e => e.Realm.Intrinsics.Btoa, PropertyFlag.ConfigurableEnumerableWritable); + } } /// diff --git a/README.md b/README.md index 1fb2f9da68..7cd817f280 100644 --- a/README.md +++ b/README.md @@ -214,8 +214,8 @@ its own `console` (or any other name in the table below), enabling the feature l | `console` (`log`/`warn`/`error`/`group`/`count`/`time`/`assert`/`trace`/`dir`) | `WebApiFeatures.Console` | ✔ shipped | | `DOMException` | *(no flag — installed whenever any feature is enabled)* | ✔ shipped | | `setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` / `queueMicrotask` | `WebApiFeatures.Timers` | ✔ shipped | -| `TextEncoder` / `TextDecoder` | `Encoding` | in progress | -| `atob` / `btoa` | `Base64` | in progress | +| `TextEncoder` / `TextDecoder` (UTF-8, UTF-16LE, UTF-16BE; `fatal`, `ignoreBOM`, streaming) | `Encoding` | ✔ shipped | +| `atob` / `btoa` | `Base64` | ✔ shipped | | `structuredClone` | `StructuredClone` | in progress | | `crypto.getRandomValues` / `crypto.randomUUID` | `Crypto` | in progress | | `performance.now` / `performance.timeOrigin` | `Performance` | in progress |