diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 891a1db8d0..32568175cd 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -5,6 +5,7 @@ on: branches: [ master ] pull_request: branches: [ master ] + workflow_dispatch: permissions: read-all diff --git a/README.md b/README.md index 4a6b28445a..e8305716ed 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The current release is Rhino 1.9.1February 15, 2026 Rhino 1.9.0December 22, 2025 Rhino 1.8.1December 2, 2025 Rhino 1.8.0January 2, 2025 @@ -132,7 +133,7 @@ this using the command: ./gradlew -q javaToolchains Not all installers seem to put JDKs in the places where Gradle can find them. When in doubt, -installatioons from [Adoptium](https://adoptium.net) seem to work on most platforms. +installations from [Adoptium](https://adoptium.net) seem to work on most platforms. ### Testing on Android diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index ef198d6b8b..94aacefae3 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,3 +1,15 @@ +# Rhino 1.9.1 +## February 15, 2026 + +This release fixes a few small regressions introduced in Rhino 1.9.0. + +* Ensure that the "global" object is present, necessary for core-js to run +* Prevent compiled methods from introducing illegal characters in their names +* Support reserved words like "class" as names of XML attributes +* Fix a performance regression in the RegExp engine. + +Thanks to all who contributed! + # Rhino 1.9.0 ## December 22, 2025 diff --git a/gradle.properties b/gradle.properties index cbbbbaac99..91c1688286 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ rootProject.name=rhino-root group=org.mozilla -version=1.9.0 +version=1.9.1-PATCHED mavenReleaseRepo=https://oss.sonatype.org/service/local/staging/deploy/maven2/ mavenSnapshotRepo=https://oss.sonatype.org/content/repositories/snapshots githubPackagesRepo=https://maven.pkg.github.com/mozilla/rhino diff --git a/rhino-tools/src/main/java/org/mozilla/javascript/tools/debugger/Dim.java b/rhino-tools/src/main/java/org/mozilla/javascript/tools/debugger/Dim.java index 143ba67e69..9af01cc080 100644 --- a/rhino-tools/src/main/java/org/mozilla/javascript/tools/debugger/Dim.java +++ b/rhino-tools/src/main/java/org/mozilla/javascript/tools/debugger/Dim.java @@ -17,7 +17,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import org.mozilla.javascript.Callable; import org.mozilla.javascript.Context; import org.mozilla.javascript.ContextAction; import org.mozilla.javascript.ContextFactory; @@ -25,6 +24,7 @@ import org.mozilla.javascript.Kit; import org.mozilla.javascript.NativeCall; import org.mozilla.javascript.NativeObject; +import org.mozilla.javascript.Script; import org.mozilla.javascript.ScriptRuntime; import org.mozilla.javascript.Scriptable; import org.mozilla.javascript.ScriptableObject; @@ -745,8 +745,15 @@ private static String do_eval(Context cx, StackFrame frame, String expr) { cx.setInterpretedMode(false); cx.setGeneratingDebug(false); try { - Callable script = (Callable) cx.compileString(expr, "", 0, null); - Object result = script.call(cx, frame.scope, frame.thisObj, ScriptRuntime.emptyArgs); + Scriptable scope = frame.scope; + if (!frame.isFunction && scope != null) { + Scriptable parentScope = scope.getParentScope(); + if (parentScope != null) { + scope = parentScope; + } + } + Script script = cx.compileString(expr, "", 0, null); + Object result = script.exec(cx, scope, frame.thisObj); if (result == Undefined.instance) { resultString = ""; } else { @@ -896,7 +903,7 @@ public DebugFrame getFrame(Context cx, DebuggableScript fnOrScript) { // Can not debug if source is not available return null; } - return new StackFrame(cx, dim, item); + return new StackFrame(cx, dim, item, fnOrScript.isFunction()); } /** Called when compilation is finished. */ @@ -974,6 +981,9 @@ public static class StackFrame implements DebugFrame { /** The 'this' object. */ private Scriptable thisObj; + /** Whether this frame represents a function (vs a top-level script). */ + private boolean isFunction; + /** Information about the function. */ private FunctionSource fsource; @@ -984,10 +994,11 @@ public static class StackFrame implements DebugFrame { private int lineNumber; /** Creates a new StackFrame. */ - private StackFrame(Context cx, Dim dim, FunctionSource fsource) { + private StackFrame(Context cx, Dim dim, FunctionSource fsource, boolean isFunction) { this.dim = dim; this.contextData = ContextData.get(cx); this.fsource = fsource; + this.isFunction = isFunction; this.breakpoints = fsource.sourceInfo().breakpoints; this.lineNumber = fsource.firstLine(); } diff --git a/rhino/src/main/java/org/mozilla/javascript/AbstractEcmaStringOperations.java b/rhino/src/main/java/org/mozilla/javascript/AbstractEcmaStringOperations.java index 98f23c749c..cc7751d404 100644 --- a/rhino/src/main/java/org/mozilla/javascript/AbstractEcmaStringOperations.java +++ b/rhino/src/main/java/org/mozilla/javascript/AbstractEcmaStringOperations.java @@ -1,57 +1,46 @@ package org.mozilla.javascript; +import java.util.ArrayList; +import java.util.List; + /** Abstract operations for string manipulation as defined by EcmaScript */ public class AbstractEcmaStringOperations { - /** - * GetSubstitution(matched, str, position, captures, namedCaptures, replacementTemplate) - * - *

22.1.3.19.1 - * GetSubstitution (matched, str, position, captures, namedCaptures, replacementTemplate) - */ - public static String getSubstitution( - Context cx, - Scriptable scope, - String matched, - String str, - int position, - NativeArray capturesArray, - Object namedCaptures, - String replacementTemplate) { - // See ECMAScript spec 22.1.3.19.1 - int stringLength = str.length(); - if (position > stringLength) Kit.codeBug(); - StringBuilder result = new StringBuilder(); - String templateRemainder = replacementTemplate; - while (!templateRemainder.isEmpty()) { - String ref = templateRemainder.substring(0, 1); - String refReplacement = ref; - - if (templateRemainder.charAt(0) == '$') { - if (templateRemainder.length() > 1) { - char c = templateRemainder.charAt(1); + + public static List buildReplacementList(String replacementTemplate) { + List ops = new ArrayList<>(); + int position = 0; + int start = 0; + + while (position < replacementTemplate.length()) { + if (replacementTemplate.charAt(position) == '$') { + if (position < (replacementTemplate.length() - 1)) { + if (start != position) { + ops.add( + new LiteralReplacement( + replacementTemplate.substring(start, position))); + } + String ref = replacementTemplate.substring(position, position + 1); + char c = replacementTemplate.charAt(position + 1); switch (c) { case '$': ref = "$$"; - refReplacement = "$"; + ops.add(new LiteralReplacement("$")); break; case '`': ref = "$`"; - refReplacement = str.substring(0, position); + ops.add(new FromStartToMatchReplacement()); break; case '&': ref = "$&"; - refReplacement = matched; + ops.add(new MatchedReplacement()); break; case '\'': { ref = "$'"; - int matchLength = matched.length(); - int tailPos = position + matchLength; - refReplacement = str.substring(Math.min(tailPos, stringLength)); + ops.add(new FromMatchToEndReplacement()); break; } @@ -67,66 +56,78 @@ public static String getSubstitution( case '9': { int digitCount = 1; - if (templateRemainder.length() > 2) { - char c2 = templateRemainder.charAt(2); + if (replacementTemplate.length() > position + 2) { + char c2 = replacementTemplate.charAt(position + 2); if (isAsciiDigit(c2)) { digitCount = 2; } } - String digits = templateRemainder.substring(1, 1 + digitCount); + String digits = + replacementTemplate.substring( + position + 1, position + 1 + digitCount); + ref = + replacementTemplate.substring( + position, position + 1 + digitCount); // No need for ScriptRuntime version; we know the string is one or // two characters and // contains only [0-9] int index = Integer.parseInt(digits); - long captureLen = capturesArray.getLength(); - if (index > captureLen && digitCount == 2) { - digitCount = 1; - digits = digits.substring(0, 1); - index = Integer.parseInt(digits); - } - ref = templateRemainder.substring(0, 1 + digitCount); - if (1 <= index && index <= captureLen) { - Object capture = capturesArray.get(index - 1); - if (capture - == null) { // Undefined or missing are returned as null - refReplacement = ""; - } else { - refReplacement = ScriptRuntime.toString(capture); - } + if (digits.length() == 1) { + ops.add(new OneDigitCaptureReplacement(index)); } else { - refReplacement = ref; + ops.add(new TwoDigitCaptureReplacement(index)); } break; } case '<': { - int gtPos = templateRemainder.indexOf('>'); - if (gtPos == -1 || Undefined.isUndefined(namedCaptures)) { + int gtPos = replacementTemplate.indexOf('>', position + 2); + if (gtPos == -1) { ref = "$<"; - refReplacement = ref; + ops.add(new LiteralReplacement(ref)); } else { - ref = templateRemainder.substring(0, gtPos + 1); - String groupName = templateRemainder.substring(2, gtPos); - Object capture = - ScriptRuntime.getObjectProp( - namedCaptures, groupName, cx, scope); - if (Undefined.isUndefined(capture)) { - refReplacement = ""; - } else { - refReplacement = ScriptRuntime.toString(capture); - } + ref = replacementTemplate.substring(position, gtPos + 1); + String groupName = + replacementTemplate.substring(position + 2, gtPos); + ops.add(new NamedCaptureReplacement(groupName)); } } break; + default: + ops.add(new LiteralReplacement(ref)); + break; } + position += ref.length(); + start = position; + } else { + position++; } + } else { + position++; } + } + if (start != position) { + ops.add(new LiteralReplacement(replacementTemplate.substring(start, position))); + } + return ops; + } - int refLength = ref.length(); - templateRemainder = templateRemainder.substring(refLength); - result.append(refReplacement); + public static String getSubstitution( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List capturesList, + Object namedCaptures, + List replacementTemplate) { + if (position > str.length()) Kit.codeBug(); + StringBuilder result = new StringBuilder(); + for (var op : replacementTemplate) { + result.append( + op.replacement(cx, scope, matched, str, position, capturesList, namedCaptures)); } return result.toString(); } @@ -149,4 +150,181 @@ private static boolean isAsciiDigit(char c) { return false; } } + + public abstract static class ReplacementOperation { + + abstract String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures); + } + + private static class LiteralReplacement extends ReplacementOperation { + + private final String replacement; + + LiteralReplacement(String replacement) { + this.replacement = replacement; + } + + @Override + String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures) { + return replacement; + } + } + + private static class OneDigitCaptureReplacement extends ReplacementOperation { + private final int capture; + + OneDigitCaptureReplacement(int capture) { + this.capture = capture; + } + + @Override + String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures) { + if (capture >= 1 && capture <= captures.size()) { + var v = captures.get(capture - 1); + if (v == null || v == Undefined.instance) { + return ""; + } else { + return v.toString(); + } + } else { + return "$" + Integer.toString(capture); + } + } + } + + private static class TwoDigitCaptureReplacement extends ReplacementOperation { + private final int capture; + + TwoDigitCaptureReplacement(int capture) { + this.capture = capture; + } + + @Override + String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures) { + int i = capture; + if (i > 9 && i > captures.size() && i / 10 <= captures.size()) { + i = i / 10; // Just take the first digit. + var v = captures.get(i - 1); + if (v == null || v == Undefined.instance) { + return "" + Integer.toString(capture % 10); + } else { + return v.toString() + Integer.toString(capture % 10); + } + } else if (i >= 1 && i <= captures.size()) { + var v = captures.get(i - 1); + if (v == null || v == Undefined.instance) { + return ""; + } else { + return v.toString(); + } + } else { + return (capture >= 10 ? "$" : "$0") + Integer.toString(capture); + } + } + } + + private static class FromStartToMatchReplacement extends ReplacementOperation { + @Override + String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures) { + return str.substring(0, position); + } + } + + private static class MatchedReplacement extends ReplacementOperation { + @Override + String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures) { + return matched; + } + } + + private static class FromMatchToEndReplacement extends ReplacementOperation { + @Override + String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures) { + int matchLength = matched.length(); + int tailPos = position + matchLength; + return str.substring(Math.min(str.length(), tailPos)); + } + } + + private static class NamedCaptureReplacement extends ReplacementOperation { + final String groupName; + + NamedCaptureReplacement(String groupName) { + this.groupName = groupName; + } + + @Override + String replacement( + Context cx, + Scriptable scope, + String matched, + String str, + int position, + List captures, + Object namedCaptures) { + if (Undefined.isUndefined(namedCaptures)) { + List ops = buildReplacementList(groupName); + return "$<" + + getSubstitution( + cx, scope, matched, str, position, captures, namedCaptures, ops) + + ">"; + } + + Object capture = ScriptRuntime.getObjectProp(namedCaptures, groupName, cx, scope); + if (Undefined.isUndefined(capture)) { + return ""; + } else { + return ScriptRuntime.toString(capture); + } + } + } } diff --git a/rhino/src/main/java/org/mozilla/javascript/CodeGenerator.java b/rhino/src/main/java/org/mozilla/javascript/CodeGenerator.java index a9d91b85ce..dc91c3ccc0 100644 --- a/rhino/src/main/java/org/mozilla/javascript/CodeGenerator.java +++ b/rhino/src/main/java/org/mozilla/javascript/CodeGenerator.java @@ -1552,7 +1552,7 @@ private void visitArrayLiteral(Node node, Node child) { } addIndexOp(Icode_LITERAL_NEW_ARRAY, count - numberOfSpread); - addUint8(skipIndexesId + 1); + addUint16(skipIndexesId + 1); stackChange(1); int childIdx = 0; @@ -1561,7 +1561,7 @@ private void visitArrayLiteral(Node node, Node child) { visitExpression(child.getFirstChild(), 0); addIcode(Icode_SPREAD); if (skipIndexes != null) { - addUint8(sourcePositions[childIdx]); + addUint16(sourcePositions[childIdx]); } stackChange(-1); } else { diff --git a/rhino/src/main/java/org/mozilla/javascript/Interpreter.java b/rhino/src/main/java/org/mozilla/javascript/Interpreter.java index 14a829b861..c3eb585747 100644 --- a/rhino/src/main/java/org/mozilla/javascript/Interpreter.java +++ b/rhino/src/main/java/org/mozilla/javascript/Interpreter.java @@ -1191,6 +1191,10 @@ private static int bytecodeSpan(int bytecode) { // make a copy or not flag return 1 + 1; + case Icode_LITERAL_NEW_ARRAY: + // skip indexes ID (uint16) + return 1 + 2; + case Icode_REG_BIGINT1: // ubyte bigint index return 1 + 1; @@ -4400,8 +4404,8 @@ NewState execute(Context cx, CallFrame frame, InterpreterState state, int op) { // indexReg: number of values in the literal NewLiteralStorage storage = NewLiteralStorage.create(cx, state.indexReg, false); - int skipIdx = 0xFF & frame.idata.itsICode[frame.pc]; - ++frame.pc; + int skipIdx = getIndex(frame.idata.itsICode, frame.pc); + frame.pc += 2; // fill in skip indexes in array literal storage if (skipIdx > 0) { // 0 - no skip index, otherwise subtract 1 from idx @@ -4468,8 +4472,8 @@ NewState execute(Context cx, CallFrame frame, InterpreterState state, int op) { NewLiteralStorage store = (NewLiteralStorage) frame.stack[state.stackTop]; if (store.hasSkipIndexes()) { - int sourcePos = 0xFF & frame.idata.itsICode[frame.pc]; - ++frame.pc; + int sourcePos = getIndex(frame.idata.itsICode, frame.pc); + frame.pc += 2; store.spread(cx, frame.scope, source, sourcePos); } else { store.spread(cx, frame.scope, source, 0); diff --git a/rhino/src/main/java/org/mozilla/javascript/NativeString.java b/rhino/src/main/java/org/mozilla/javascript/NativeString.java index 90a8b9a9d7..97271653f0 100644 --- a/rhino/src/main/java/org/mozilla/javascript/NativeString.java +++ b/rhino/src/main/java/org/mozilla/javascript/NativeString.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import org.mozilla.javascript.AbstractEcmaStringOperations.ReplacementOperation; import org.mozilla.javascript.ScriptRuntime.StringIdOrIndex; /** @@ -910,8 +911,14 @@ private static Object js_replace( String string = ScriptRuntime.toString(o); String searchString = ScriptRuntime.toString(searchValue); boolean functionalReplace = replaceValue instanceof Callable; + List replaceOps; + if (!functionalReplace) { - replaceValue = ScriptRuntime.toString(replaceValue); + replaceOps = + AbstractEcmaStringOperations.buildReplacementList( + ScriptRuntime.toString(replaceValue)); + } else { + replaceOps = List.of(); } int searchLength = searchString.length(); int position = string.indexOf(searchString); @@ -937,7 +944,7 @@ private static Object js_replace( }); replacement = ScriptRuntime.toString(replacementObj); } else { - NativeArray captures = (NativeArray) cx.newArray(scope, 0); + List captures = List.of(); replacement = AbstractEcmaStringOperations.getSubstitution( cx, @@ -947,7 +954,7 @@ private static Object js_replace( position, captures, Undefined.SCRIPTABLE_UNDEFINED, - (String) replaceValue); + replaceOps); } return preceding + replacement + following; } @@ -992,8 +999,13 @@ private static Object js_replaceAll( String string = ScriptRuntime.toString(o); String searchString = ScriptRuntime.toString(searchValue); boolean functionalReplace = replaceValue instanceof Callable; + List replaceOps; if (!functionalReplace) { - replaceValue = ScriptRuntime.toString(replaceValue); + replaceOps = + AbstractEcmaStringOperations.buildReplacementList( + ScriptRuntime.toString(replaceValue)); + } else { + replaceOps = List.of(); } int searchLength = searchString.length(); int advanceBy = Math.max(1, searchLength); @@ -1029,7 +1041,7 @@ private static Object js_replaceAll( }); replacement = ScriptRuntime.toString(replacementObj); } else { - NativeArray captures = (NativeArray) cx.newArray(scope, 0); + List captures = List.of(); replacement = AbstractEcmaStringOperations.getSubstitution( cx, @@ -1039,7 +1051,7 @@ private static Object js_replaceAll( p, captures, Undefined.SCRIPTABLE_UNDEFINED, - (String) replaceValue); + replaceOps); } result.append(preserved); result.append(replacement); diff --git a/rhino/src/main/java/org/mozilla/javascript/Parser.java b/rhino/src/main/java/org/mozilla/javascript/Parser.java index d9b29d441f..de1de39b11 100644 --- a/rhino/src/main/java/org/mozilla/javascript/Parser.java +++ b/rhino/src/main/java/org/mozilla/javascript/Parser.java @@ -3269,7 +3269,10 @@ private AstNode attributeAccess() throws IOException { // handles: @name, @ns::name, @ns::*, @ns::[expr] case Token.NAME: return propertyName(atPos, 0); - + case Token.RESERVED: + String name = ts.getString(); + saveNameTokenData(ts.tokenBeg, name, lineNumber(), columnNumber()); + return propertyName(atPos, 0); // handles: @*, @*::name, @*::*, @*::[expr] case Token.MUL: saveNameTokenData(ts.tokenBeg, "*", lineNumber(), columnNumber()); @@ -3280,6 +3283,16 @@ private AstNode attributeAccess() throws IOException { return xmlElemRef(atPos, null, -1); default: + { + if (compilerEnv.isReservedKeywordAsIdentifier()) { + // allow keywords as property names, e.g. ({if: 1}) + name = Token.keywordToName(tt); + if (name != null) { + saveNameTokenData(ts.tokenBeg, name, lineNumber(), columnNumber()); + return propertyName(atPos, 0); + } + } + } reportError("msg.no.name.after.xmlAttr"); return makeErrorNode(); } @@ -3304,13 +3317,19 @@ private AstNode propertyName(int atPos, int memberTypeFlags) throws IOException ns = name; colonPos = ts.tokenBeg; - switch (nextToken()) { + int nt = nextToken(); + switch (nt) { // handles name::name case Token.NAME: name = createNameNode(); break; - - // handles name::* + case Token.RESERVED: + { + String realName = ts.getString(); + saveNameTokenData(ts.tokenBeg, realName, lineNumber(), columnNumber()); + name = createNameNode(false, -1); + break; + } case Token.MUL: saveNameTokenData(ts.tokenBeg, "*", lineNumber(), columnNumber()); name = createNameNode(false, -1); @@ -3321,6 +3340,18 @@ private AstNode propertyName(int atPos, int memberTypeFlags) throws IOException return xmlElemRef(atPos, ns, colonPos); default: + { + if (compilerEnv.isReservedKeywordAsIdentifier()) { + // allow keywords as property names, e.g. ({if: 1}) + String realName = Token.keywordToName(nt); + if (name != null) { + saveNameTokenData( + ts.tokenBeg, realName, lineNumber(), columnNumber()); + name = createNameNode(false, -1); + break; + } + } + } reportError("msg.no.name.after.coloncolon"); return makeErrorNode(); } diff --git a/rhino/src/main/java/org/mozilla/javascript/ScriptRuntime.java b/rhino/src/main/java/org/mozilla/javascript/ScriptRuntime.java index 8ee7837ce5..7eefee67e8 100644 --- a/rhino/src/main/java/org/mozilla/javascript/ScriptRuntime.java +++ b/rhino/src/main/java/org/mozilla/javascript/ScriptRuntime.java @@ -190,6 +190,8 @@ public static ScriptableObject initSafeStandardObjects( ((TopLevel) scope).clearCache(); } + scope.put("global", scope, scope); + scope.associateValue(LIBRARY_SCOPE_KEY, scope); new ClassCache().associate(scope); new ConcurrentFactory().associate(scope); diff --git a/rhino/src/main/java/org/mozilla/javascript/optimizer/Codegen.java b/rhino/src/main/java/org/mozilla/javascript/optimizer/Codegen.java index f04e080aaa..cfb1612271 100644 --- a/rhino/src/main/java/org/mozilla/javascript/optimizer/Codegen.java +++ b/rhino/src/main/java/org/mozilla/javascript/optimizer/Codegen.java @@ -17,6 +17,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.regex.Pattern; import org.mozilla.classfile.ByteCode; import org.mozilla.classfile.ClassFileWriter; import org.mozilla.javascript.CodeGenUtils; @@ -860,6 +861,12 @@ String getBodyMethodName(ScriptNode n, int index) { return "_c_" + cleanName(n) + "_" + index; } + /** + * List of illegal characters in unqualified names as specified in + * https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-4.html#jvms-4.2.2 + */ + private static Pattern illegalChars = Pattern.compile("[.;\\[/<>]"); + /** Gets a Java-compatible "informative" name for the ScriptOrFnNode */ String cleanName(final ScriptNode n) { String result = ""; @@ -873,7 +880,7 @@ String cleanName(final ScriptNode n) { } else { result = "script"; } - return result; + return illegalChars.matcher(result).replaceAll("_"); } String getNonDirectBodyMethodSIgnature(ScriptNode n) { diff --git a/rhino/src/main/java/org/mozilla/javascript/regexp/NativeRegExp.java b/rhino/src/main/java/org/mozilla/javascript/regexp/NativeRegExp.java index a3781dbf3b..496840658d 100644 --- a/rhino/src/main/java/org/mozilla/javascript/regexp/NativeRegExp.java +++ b/rhino/src/main/java/org/mozilla/javascript/regexp/NativeRegExp.java @@ -9,10 +9,12 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.mozilla.javascript.AbstractEcmaObjectOperations; import org.mozilla.javascript.AbstractEcmaStringOperations; +import org.mozilla.javascript.AbstractEcmaStringOperations.ReplacementOperation; import org.mozilla.javascript.Callable; import org.mozilla.javascript.Constructable; import org.mozilla.javascript.Context; @@ -3504,10 +3506,70 @@ && upcase(matchCh) == upcase((char) anchorCodePoint))) { return false; } + private static class ExecResult { + final String match; + final ArrayList captures = new ArrayList<>(); + final LinkedHashMap groups = new LinkedHashMap<>(); + final int index; + final String input; + + ExecResult(int index, String input) { + this.match = null; + this.index = index; + this.input = input; + } + + ExecResult(int index, String input, String match) { + this.match = match; + this.index = index; + this.input = input; + } + } + + Object executeRegExp( + Context cx, Scriptable scope, RegExpImpl res, String str, int[] indexp, int matchType) { + var result = executeRegExpInternal(cx, scope, res, str, indexp, matchType); + + if (result == null) { + if (matchType != PREFIX) return null; + return Undefined.instance; + } else if (matchType == TEST) { + /* + * Testing for a match and updating cx.regExpImpl: don't allocate + * an array object, do return true. + */ + return Boolean.TRUE; + } else { + Object[] captures = result.captures.toArray(); + Scriptable obj = cx.newArray(scope, captures.length + 1); + + obj.put(0, obj, result.match); + for (int i = 0; i < captures.length; i++) { + obj.put(i + 1, obj, (captures[i] == null) ? Undefined.instance : captures[i]); + } + + obj.put("index", obj, Integer.valueOf(result.index)); + obj.put("input", obj, str); + if (!result.groups.isEmpty()) { + var groups = new NativeObject(); + for (var g : result.groups.entrySet()) { + groups.put( + g.getKey(), + groups, + g.getValue() == null ? Undefined.instance : g.getValue()); + } + obj.put("groups", obj, groups); + } else { + obj.put("groups", obj, Undefined.instance); + } + return obj; + } + } + /* * indexp is assumed to be an array of length 1 */ - Object executeRegExp( + ExecResult executeRegExpInternal( Context cx, Scriptable scope, RegExpImpl res, String str, int[] indexp, int matchType) { REGlobalData gData = new REGlobalData(); @@ -3518,25 +3580,16 @@ Object executeRegExp( // Call the recursive matcher to do the real work. // boolean matches = matchRegExp(cx, gData, re, str, start, end, res.multiline); - if (!matches) { - if (matchType != PREFIX) return null; - return Undefined.instance; - } + if (!matches) return null; + int index = gData.cp; int ep = indexp[0] = index; int matchlen = ep - (start + gData.skipped); index -= matchlen; - Object result; - Scriptable obj; - Scriptable groups = Undefined.SCRIPTABLE_UNDEFINED; + ExecResult result; if (matchType == TEST) { - /* - * Testing for a match and updating cx.regExpImpl: don't allocate - * an array object, do return true. - */ - result = Boolean.TRUE; - obj = null; + result = new ExecResult(index, str); } else { /* * The array returned on match has element 0 bound to the matched @@ -3544,11 +3597,9 @@ Object executeRegExp( * matches, an index property telling the length of the left context, * and an input property referring to the input string. */ - result = cx.newArray(scope, 0); - obj = (Scriptable) result; String matchstr = str.substring(index, index + matchlen); - obj.put(0, obj, matchstr); + result = new ExecResult(index, str, matchstr); } if (re.parenCount == 0) { @@ -3562,11 +3613,6 @@ Object executeRegExp( if (matchType != TEST) { namedCaptureGroups = new String[re.parenCount]; - if (!re.namedCaptureGroups.isEmpty()) { - // We do a new NativeObject() and not cx.newObject(scope) - // since we want the groups to have null as prototype - groups = new NativeObject(); - } for (Map.Entry> entry : re.namedCaptureGroups.entrySet()) { String key = entry.getKey(); List indices = entry.getValue(); @@ -3584,17 +3630,17 @@ Object executeRegExp( parsub = new SubString(str, cap_index, cap_length); res.parens[num] = parsub; if (matchType != TEST) { - obj.put(num + 1, obj, parsub.toString()); + result.captures.add(parsub.toString()); if (namedCaptureGroups[num] != null) { - groups.put(namedCaptureGroups[num], groups, parsub.toString()); + result.groups.put(namedCaptureGroups[num], parsub.toString()); } } } else { + result.captures.add(null); if (matchType != TEST) { - obj.put(num + 1, obj, Undefined.instance); if (namedCaptureGroups[num] != null - && !groups.has(namedCaptureGroups[num], groups)) { - groups.put(namedCaptureGroups[num], groups, Undefined.instance); + && !result.groups.containsKey(namedCaptureGroups[num])) { + result.groups.put(namedCaptureGroups[num], null); } } } @@ -3602,16 +3648,6 @@ Object executeRegExp( res.lastParen = parsub; } - if (!(matchType == TEST)) { - /* - * Define the index and input properties last for better for/in loop - * order (so they come after the elements). - */ - obj.put("index", obj, Integer.valueOf(start + gData.skipped)); - obj.put("input", obj, str); - obj.put("groups", obj, groups); - } - if (res.lastMatch == null) { res.lastMatch = new SubString(); res.leftContext = new SubString(); @@ -3807,7 +3843,7 @@ private void setLastIndex(ScriptableObject thisObj, Object value) { setLastIndex((Scriptable) thisObj, value); } - private void setLastIndex(Scriptable thisObj, Object value) { + private static void setLastIndex(Scriptable thisObj, Object value) { ScriptableObject.putProperty(thisObj, "lastIndex", value); } @@ -4061,6 +4097,137 @@ private Object js_SymbolMatchAll( private Object js_SymbolReplace( Context cx, Scriptable scope, Scriptable thisObj, Object[] args) { + if (thisObj instanceof NativeRegExp) { + var regexp = (NativeRegExp) thisObj; + var exec = ScriptableObject.getProperty(regexp, "exec"); + if ((regexp.lastIndexAttr & READONLY) == 0 + && exec instanceof IdFunctionObject + && ((IdFunctionObject) exec).methodId() == Id_exec + && ((IdFunctionObject) exec).getTag() == REGEXP_TAG) + return regexp.js_SymbolReplaceFast(cx, scope, (NativeRegExp) thisObj, args); + } + return js_SymbolReplaceSlow(cx, scope, thisObj, args); + } + + private Object js_SymbolReplaceFast( + Context cx, Scriptable scope, NativeRegExp thisObj, Object[] args) { + String s = ScriptRuntime.toString(args.length > 0 ? args[0] : Undefined.instance); + int lengthS = s.length(); + Object replaceValue = args.length > 1 ? args[1] : Undefined.instance; + boolean functionalReplace = replaceValue instanceof Callable; + List replaceOps; + Callable replaceFn; + if (!functionalReplace) { + replaceFn = null; + replaceOps = + AbstractEcmaStringOperations.buildReplacementList( + ScriptRuntime.toString(replaceValue)); + } else { + replaceFn = (Callable) replaceValue; + replaceOps = List.of(); + } + String flags = ScriptRuntime.toString(ScriptRuntime.getObjectProp(thisObj, "flags", cx)); + boolean fullUnicode = flags.indexOf('u') != -1 || flags.indexOf('v') != -1; + + List results = new ArrayList<>(); + boolean done = false; + + RegExpImpl reImpl = getImpl(cx); + boolean sticky = (re.flags & JSREG_STICKY) != 0; + boolean global = (re.flags & JSREG_GLOB) != 0; + + int[] indexp = {0}; + if (sticky) { + indexp[0] = (int) getLastIndex(cx, thisObj); + } + while (!done) { + ExecResult result; + if (indexp[0] < 0 || indexp[0] > s.length()) { + result = null; + } else { + result = executeRegExpInternal(cx, scope, reImpl, s, indexp, MATCH); + } + if (result == null) { + if (global || sticky) { + indexp[0] = 0; + } + done = true; + } else { + results.add(result); + if (!global) { + done = true; + } else { + String matchStr = result.match; + if (matchStr.isEmpty()) { + indexp[0] = + (int) ScriptRuntime.advanceStringIndex(s, indexp[0], fullUnicode); + } + } + } + } + setLastIndex(thisObj, indexp[0]); + + StringBuilder accumulatedResult = new StringBuilder(); + int nextSourcePosition = 0; + for (ExecResult result : results) { + String matched = result.match; + int matchLength = matched.length(); + double positionDbl = result.index; + int position = ScriptRuntime.clamp((int) positionDbl, 0, lengthS); + + List captures = result.captures; + Object namedCaptures; + if (!result.groups.isEmpty()) { + var groups = new NativeObject(); + for (var g : result.groups.entrySet()) { + groups.put( + g.getKey(), + groups, + g.getValue() == null ? Undefined.instance : g.getValue()); + } + namedCaptures = groups; + } else { + namedCaptures = Undefined.instance; + } + + String replacementString = + functionalReplace + ? makeComplexReplacement( + cx, + scope, + matched, + captures, + position, + s, + namedCaptures, + replaceFn) + : makeSimpleReplacement( + cx, + scope, + matched, + captures, + position, + s, + namedCaptures, + replaceOps); + + if (position >= nextSourcePosition) { + accumulatedResult.append(s, nextSourcePosition, position); + accumulatedResult.append(replacementString); + nextSourcePosition = position + matchLength; + } + } + + if (nextSourcePosition >= lengthS) { + return accumulatedResult.toString(); + } else { + accumulatedResult.append(s.substring(nextSourcePosition)); + return accumulatedResult.toString(); + } + } + + private Object js_SymbolReplaceSlow( + Context cx, Scriptable scope, Scriptable thisObj, Object[] args) { // See ECMAScript spec 22.2.6.11 if (!ScriptRuntime.isObject(thisObj)) { throw ScriptRuntime.typeErrorById("msg.arg.not.object", ScriptRuntime.typeof(thisObj)); @@ -4070,8 +4237,17 @@ private Object js_SymbolReplace( int lengthS = s.length(); Object replaceValue = args.length > 1 ? args[1] : Undefined.instance; boolean functionalReplace = replaceValue instanceof Callable; + List replaceOps; + Callable replaceFn; + if (!functionalReplace) { - replaceValue = ScriptRuntime.toString(replaceValue); + replaceFn = null; + replaceOps = + AbstractEcmaStringOperations.buildReplacementList( + ScriptRuntime.toString(replaceValue)); + } else { + replaceFn = (Callable) replaceValue; + replaceOps = List.of(); } String flags = ScriptRuntime.toString(ScriptRuntime.getObjectProp(thisObj, "flags", cx)); boolean global = flags.indexOf('g') != -1; @@ -4131,41 +4307,26 @@ private Object js_SymbolReplace( } Object namedCaptures = ScriptRuntime.getObjectProp(result, "groups", cx, scope); - String replacementString; - - if (functionalReplace) { - List replacerArgs = new ArrayList<>(); - replacerArgs.add(matched); - replacerArgs.addAll(captures); - replacerArgs.add(position); - replacerArgs.add(s); - if (!Undefined.isUndefined(namedCaptures)) { - replacerArgs.add(namedCaptures); - } - - Scriptable callThis = - ScriptRuntime.getApplyOrCallThis( - cx, scope, null, 0, (Callable) replaceValue); - Object replacementValue = - ((Callable) replaceValue).call(cx, scope, callThis, replacerArgs.toArray()); - replacementString = ScriptRuntime.toString(replacementValue); - } else { - if (!Undefined.isUndefined(namedCaptures)) { - namedCaptures = ScriptRuntime.toObject(scope, namedCaptures); - } - - NativeArray capturesArray = (NativeArray) cx.newArray(scope, captures.toArray()); - replacementString = - AbstractEcmaStringOperations.getSubstitution( - cx, - scope, - matched, - s, - position, - capturesArray, - namedCaptures, - (String) replaceValue); - } + String replacementString = + functionalReplace + ? makeComplexReplacement( + cx, + scope, + matched, + captures, + position, + s, + namedCaptures, + replaceFn) + : makeSimpleReplacement( + cx, + scope, + matched, + captures, + position, + s, + namedCaptures, + replaceOps); if (position >= nextSourcePosition) { accumulatedResult.append(s, nextSourcePosition, position); @@ -4182,6 +4343,51 @@ private Object js_SymbolReplace( } } + private String makeComplexReplacement( + Context cx, + Scriptable scope, + String matched, + List captures, + int position, + String s, + Object namedCaptures, + Callable replaceFunction) { + Object[] replacerArgs = + new Object[1 + captures.size() + (Undefined.isUndefined(namedCaptures) ? 2 : 3)]; + replacerArgs[0] = matched; + int i = 1; + for (; i <= captures.size(); i++) { + var capture = captures.get(i - 1); + replacerArgs[i] = capture == null ? Undefined.instance : capture; + } + replacerArgs[i++] = position; + replacerArgs[i++] = s; + if (!Undefined.isUndefined(namedCaptures)) { + replacerArgs[i++] = namedCaptures; + } + + Scriptable callThis = ScriptRuntime.getApplyOrCallThis(cx, scope, null, 0, replaceFunction); + Object replacementValue = replaceFunction.call(cx, scope, callThis, replacerArgs); + return ScriptRuntime.toString(replacementValue); + } + + private String makeSimpleReplacement( + Context cx, + Scriptable scope, + String matched, + List captures, + int position, + String s, + Object namedCaptures, + List replaceOps) { + if (!Undefined.isUndefined(namedCaptures)) { + namedCaptures = ScriptRuntime.toObject(scope, namedCaptures); + } + + return AbstractEcmaStringOperations.getSubstitution( + cx, scope, matched, s, position, captures, namedCaptures, replaceOps); + } + private Object js_SymbolSplit(Context cx, Scriptable scope, Scriptable rx, Object[] args) { // See ECMAScript spec 22.2.6.14 if (!ScriptRuntime.isObject(rx)) { @@ -4198,13 +4404,10 @@ private Object js_SymbolSplit(Context cx, Scriptable scope, Scriptable rx, Objec String flags = ScriptRuntime.toString(ScriptRuntime.getObjectProp(rx, "flags", cx)); boolean unicodeMatching = flags.indexOf('u') != -1 || flags.indexOf('v') != -1; + NativeArray a = (NativeArray) cx.newArray(scope, 0); String newFlags = flags.indexOf('y') != -1 ? flags : (flags + "y"); - Scriptable splitter = c.construct(cx, scope, new Object[] {rx, newFlags}); - NativeArray a = (NativeArray) cx.newArray(scope, 0); - int lengthA = 0; - Object limit = args.length > 1 ? args[1] : Undefined.instance; long lim; if (Undefined.isUndefined(limit)) { @@ -4216,6 +4419,30 @@ private Object js_SymbolSplit(Context cx, Scriptable scope, Scriptable rx, Objec return a; } + if (splitter instanceof NativeRegExp) { + var regexp = (NativeRegExp) splitter; + var exec = ScriptableObject.getProperty(regexp, "exec"); + if ((regexp.lastIndexAttr & READONLY) == 0 + && exec instanceof IdFunctionObject + && ((IdFunctionObject) exec).methodId() == Id_exec + && ((IdFunctionObject) exec).getTag() == REGEXP_TAG) + return js_SymbolSplitFast( + cx, scope, (NativeRegExp) splitter, s, lim, unicodeMatching, a); + } + + return js_SymbolSplitSlow(cx, scope, splitter, s, lim, unicodeMatching, a); + } + + private static Object js_SymbolSplitSlow( + Context cx, + Scriptable scope, + Scriptable splitter, + String s, + long lim, + boolean unicodeMatching, + NativeArray a) { + int lengthA = 0; + if (s.isEmpty()) { Object z = regExpExec(splitter, s, cx, scope); if (z != null) { @@ -4270,6 +4497,72 @@ private Object js_SymbolSplit(Context cx, Scriptable scope, Scriptable rx, Objec return a; } + private static Object js_SymbolSplitFast( + Context cx, + Scriptable scope, + NativeRegExp splitter, + String s, + long lim, + boolean unicodeMatching, + NativeArray a) { + int lengthA = 0; + + int[] indexp = {0}; + RegExpImpl reImpl = getImpl(cx); + if (s.isEmpty()) { + ExecResult result = splitter.executeRegExpInternal(cx, scope, reImpl, s, indexp, MATCH); + if (result != null) { + return a; + } + a.put(0, a, s); + return a; + } + + int size = s.length(); + long p = 0; + long q = p; + while (q < size) { + indexp[0] = (int) q; + ExecResult result = splitter.executeRegExpInternal(cx, scope, reImpl, s, indexp, MATCH); + + if (result == null) { + q = ScriptRuntime.advanceStringIndex(s, q, unicodeMatching); + } else { + long e = indexp[0]; + e = Math.min(e, size); + if (e == p) { + q = ScriptRuntime.advanceStringIndex(s, q, unicodeMatching); + } else { + String t = s.substring((int) p, (int) q); + a.put((int) a.getLength(), a, t); + lengthA++; + if (a.getLength() == lim) { + return a; + } + + p = e; + int i = 0; + while (i < result.captures.size()) { + Object nextCapture = result.captures.get(i); + a.put( + (int) a.getLength(), + a, + nextCapture == null ? Undefined.instance : nextCapture); + i = i + 1; + lengthA++; + if (lengthA == lim) { + return a; + } + } + q = p; + } + } + } + String t = s.substring((int) p, size); + a.put((int) a.getLength(), a, t); + return a; + } + private static long getLastIndex(Context cx, Scriptable thisObj) { return ScriptRuntime.toLength(ScriptRuntime.getObjectProp(thisObj, "lastIndex", cx)); } diff --git a/rhino/src/test/java/org/mozilla/javascript/tests/ArrayLiteralOverflowTest.java b/rhino/src/test/java/org/mozilla/javascript/tests/ArrayLiteralOverflowTest.java new file mode 100644 index 0000000000..542215c2b6 --- /dev/null +++ b/rhino/src/test/java/org/mozilla/javascript/tests/ArrayLiteralOverflowTest.java @@ -0,0 +1,90 @@ +package org.mozilla.javascript.tests; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; +import org.mozilla.javascript.Scriptable; + +/** + * Regression test for addUint8 overflow in CodeGenerator.visitArrayLiteral. + * When a script contains more than 255 object/array literals, the literalIds + * table grows beyond 255, and skipIndexesId (encoded as uint8) overflows. + */ +public class ArrayLiteralOverflowTest { + + @Test + public void testLiteralIdsOverflowWithSparseArrayInterpreted() { + runLiteralIdsOverflow(-1); + } + + @Test + public void testLiteralIdsOverflowWithSparseArrayCompiled() { + runLiteralIdsOverflow(9); + } + + @Test + public void testManySpreadArrayLiteralsInterpreted() { + runManySpreadArrayLiterals(-1); + } + + @Test + public void testManySpreadArrayLiteralsCompiled() { + runManySpreadArrayLiterals(9); + } + + /** + * Each object literal {k:v} adds one entry to literalIds. + * After 256 object literals, the next sparse array literal (with elisions) + * gets a skipIndexesId > 255, which overflows the uint8 encoding. + */ + private void runLiteralIdsOverflow(int optimizationLevel) { + StringBuilder sb = new StringBuilder(); + // Create 260 object literals to push literalIds past 255 + for (int i = 0; i < 260; i++) { + sb.append("var o").append(i).append(" = {k: ").append(i).append("};\n"); + } + // Now create a sparse array (with elision) that needs skipIndexes + // [1, , 3] has a "hole" at index 1 which triggers skipIndexes + sb.append("var sparse = [1, , 3];\n"); + sb.append("sparse[0] + sparse[2];\n"); + String script = sb.toString(); + + ContextFactory factory = new ContextFactory(); + factory.call(cx -> { + cx.setOptimizationLevel(optimizationLevel); + cx.setLanguageVersion(Context.VERSION_ES6); + Scriptable scope = cx.initStandardObjects(); + Object result = cx.evaluateString(scope, script, "test.js", 1, null); + assertEquals(4.0, ((Number) result).doubleValue(), 0.0); + return null; + }); + } + + /** + * Tests spread with sparse arrays when literalIds has grown past 255. + */ + private void runManySpreadArrayLiterals(int optimizationLevel) { + StringBuilder sb = new StringBuilder(); + sb.append("var base = [0];\n"); + // 260 object literals to fill literalIds + for (int i = 0; i < 260; i++) { + sb.append("var o").append(i).append(" = {k: ").append(i).append("};\n"); + } + // Sparse array with spread: triggers both skipIndexesId and sourcePositions overflow + sb.append("var result = [...base, , 42];\n"); + sb.append("result[2];\n"); + String script = sb.toString(); + + ContextFactory factory = new ContextFactory(); + factory.call(cx -> { + cx.setOptimizationLevel(optimizationLevel); + cx.setLanguageVersion(Context.VERSION_ES6); + Scriptable scope = cx.initStandardObjects(); + Object result = cx.evaluateString(scope, script, "test.js", 1, null); + assertEquals(42.0, ((Number) result).doubleValue(), 0.0); + return null; + }); + } +} diff --git a/tests/src/test/java/org/mozilla/javascript/tests/TopLevelTest.java b/tests/src/test/java/org/mozilla/javascript/tests/TopLevelTest.java new file mode 100644 index 0000000000..0e552fa633 --- /dev/null +++ b/tests/src/test/java/org/mozilla/javascript/tests/TopLevelTest.java @@ -0,0 +1,7 @@ +package org.mozilla.javascript.tests; + +import org.mozilla.javascript.drivers.RhinoTest; +import org.mozilla.javascript.drivers.ScriptTestsBase; + +@RhinoTest(value = "testsrc/jstests/top-level.js") +public class TopLevelTest extends ScriptTestsBase {} diff --git a/tests/src/test/java/org/mozilla/javascript/tests/XMLReservedWordsAttributesTest.java b/tests/src/test/java/org/mozilla/javascript/tests/XMLReservedWordsAttributesTest.java new file mode 100644 index 0000000000..07be747588 --- /dev/null +++ b/tests/src/test/java/org/mozilla/javascript/tests/XMLReservedWordsAttributesTest.java @@ -0,0 +1,10 @@ +package org.mozilla.javascript.tests; + +import org.mozilla.javascript.Context; +import org.mozilla.javascript.drivers.LanguageVersion; +import org.mozilla.javascript.drivers.RhinoTest; +import org.mozilla.javascript.drivers.ScriptTestsBase; + +@RhinoTest("testsrc/jstests/xml-reserved-words-as-attributes.js") +@LanguageVersion(Context.VERSION_ES6) +public class XMLReservedWordsAttributesTest extends ScriptTestsBase {} diff --git a/tests/testsrc/jstests/top-level.js b/tests/testsrc/jstests/top-level.js new file mode 100644 index 0000000000..65e83ecc89 --- /dev/null +++ b/tests/testsrc/jstests/top-level.js @@ -0,0 +1,7 @@ +'use strict'; + +load("testsrc/assert.js"); + +assertSame(global, globalThis); + +"success"; diff --git a/tests/testsrc/jstests/xml-reserved-words-as-attributes.js b/tests/testsrc/jstests/xml-reserved-words-as-attributes.js new file mode 100644 index 0000000000..887d7db8f8 --- /dev/null +++ b/tests/testsrc/jstests/xml-reserved-words-as-attributes.js @@ -0,0 +1,23 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Test that reserved words can be used as both XML attribute names +// and as part of a name space qualified name. + +load('testsrc/assert.js'); + +x = + + + + + + +assertEquals("value1", x.bravo.@for.toXMLString()); +assertEquals("value2", x.bravo.charlie.@class.toXMLString()); +n = new Namespace("http://someuri"); +assertEquals("value3", x.bravo.@n::for.toXMLString()); +assertEquals("value4", x.bravo.charlie.@n::class.toXMLString()); + +"success"