JavaScriptIsolate.java

/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.javascriptengine;

import android.annotation.SuppressLint;
import android.content.res.AssetFileDescriptor;
import android.os.Binder;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresFeature;
import androidx.core.util.Consumer;

import com.google.common.util.concurrent.ListenableFuture;

import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolate;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolateClient;

import java.util.Objects;
import java.util.concurrent.Executor;

import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.ThreadSafe;

/**
 * Environment within a {@link JavaScriptSandbox} where JavaScript is executed.
 * <p>
 * A single {@link JavaScriptSandbox} process can contain any number of {@link JavaScriptIsolate}
 * instances where JS can be evaluated independently and in parallel.
 * <p>
 * Each isolate has its own state and JS global object,
 * and cannot interact with any other isolate through JS APIs. There is only a <em>moderate</em>
 * security boundary between isolates in a single {@link JavaScriptSandbox}. If the code in one
 * {@link JavaScriptIsolate} is able to compromise the security of the JS engine then it may be
 * able to observe or manipulate other isolates, since they run in the same process. For strong
 * isolation multiple {@link JavaScriptSandbox} processes should be used, but it is not supported
 * at the moment. Please find the feature request <a href="https://crbug.com/1349860">here</a>.
 * <p>
 * This class is thread-safe.
 */
@ThreadSafe
public final class JavaScriptIsolate implements AutoCloseable {
    private static final String TAG = "JavaScriptIsolate";
    private final Object mLock = new Object();
    private final CloseGuardHelper mGuard = CloseGuardHelper.create();

    @NonNull
    final JavaScriptSandbox mJsSandbox;

    @GuardedBy("mLock")
    @NonNull
    private IsolateState mIsolateState;

    private final class JsSandboxIsolateClient extends IJsSandboxIsolateClient.Stub {
        JsSandboxIsolateClient() {}

        @Override
        public void onTerminated(int status, String message) {
            final long identity = Binder.clearCallingIdentity();
            try {
                // If we're already closed, this will do nothing
                maybeSetIsolateDead(new TerminationInfo(status, message));
            } finally {
                Binder.restoreCallingIdentity(identity);
            }
        }
    }

    @NonNull
    static JavaScriptIsolate create(@NonNull JavaScriptSandbox sandbox,
            IsolateStartupParameters settings) throws RemoteException {
        final JavaScriptIsolate isolate = new JavaScriptIsolate(sandbox);
        isolate.initialize(settings);
        isolate.mGuard.open("close");
        return isolate;
    }

    @NonNull
    static JavaScriptIsolate createDead(@NonNull JavaScriptSandbox sandbox,
            @NonNull String message) {
        final JavaScriptIsolate isolate = new JavaScriptIsolate(sandbox);
        final TerminationInfo terminationInfo =
                new TerminationInfo(TerminationInfo.STATUS_SANDBOX_DEAD, message);
        synchronized (isolate.mLock) {
            isolate.mIsolateState = new EnvironmentDeadState(terminationInfo);
        }
        isolate.mGuard.open("close");
        return isolate;
    }

    private JavaScriptIsolate(@NonNull JavaScriptSandbox sandbox) {
        mJsSandbox = sandbox;
        synchronized (mLock) {
            mIsolateState = new IsolateClosedState("isolate not initialized");
        }
    }

    // Create an isolate on the service side and complete initialization.
    // This is done outside of the constructor to avoid leaking a partially constructed
    // JavaScriptIsolate to the service (which would complicate thread-safety).
    private void initialize(@NonNull IsolateStartupParameters settings) throws RemoteException {
        synchronized (mLock) {
            final IJsSandboxIsolateClient instanceCallback;
            if (mJsSandbox.isFeatureSupported(
                    JavaScriptSandbox.JS_FEATURE_ISOLATE_CLIENT)) {
                instanceCallback = new JsSandboxIsolateClient();
            } else {
                instanceCallback = null;
            }
            IJsSandboxIsolate jsIsolateStub = mJsSandbox.createIsolateOnService(settings,
                    instanceCallback);
            mIsolateState = new IsolateUsableState(this, jsIsolateStub,
                    settings.getMaxEvaluationReturnSizeBytes());
        }
    }

    /**
     * Changes the state to denote that the isolate is dead.
     * <p>
     * {@link IsolateClosedState} takes precedence so it will not change state if the current state
     * is {@link IsolateClosedState}.
     * <p>
     * If the isolate is already dead, the existing dead state is preserved.
     *
     * @return true iff the state was changed to a new EnvironmentDeadState
     */
    boolean maybeSetIsolateDead(@NonNull TerminationInfo terminationInfo) {
        synchronized (mLock) {
            if (terminationInfo.getStatus() == TerminationInfo.STATUS_MEMORY_LIMIT_EXCEEDED) {
                Log.e(TAG, "isolate exceeded its heap memory limit - killing sandbox");
                mJsSandbox.kill();
            }
            final IsolateState oldState = mIsolateState;
            if (oldState.canDie()) {
                mIsolateState = new EnvironmentDeadState(terminationInfo);
                oldState.onDied(terminationInfo);
                return true;
            }
        }
        return false;
    }

    /**
     * Changes the state to denote that the sandbox is dead.
     * <p>
     * See {@link #maybeSetIsolateDead(TerminationInfo)} for additional information.
     *
     * @return the generated termination info if it was set, or null if the state did not change
     */
    @Nullable
    TerminationInfo maybeSetSandboxDead() {
        synchronized (mLock) {
            final TerminationInfo terminationInfo =
                    new TerminationInfo(TerminationInfo.STATUS_SANDBOX_DEAD, "sandbox dead");
            if (maybeSetIsolateDead(terminationInfo)) {
                return terminationInfo;
            } else {
                return null;
            }
        }
    }

    /**
     * Evaluates the given JavaScript code and returns the result.
     * <p>
     * There are 3 possible behaviors based on the output of the expression:
     * <ul>
     *   <li><strong>If the JS expression evaluates to a JS String</strong>, then the Java Future
     * resolves to a Java String.</li>
     *   <li><strong>If the JS expression evaluates to a JS Promise</strong>,
     * and if {@link JavaScriptSandbox#isFeatureSupported(String)} for
     * {@link JavaScriptSandbox#JS_FEATURE_PROMISE_RETURN} returns {@code true}, the Java Future
     * resolves to a Java String once the promise resolves. If it returns {@code false}, then the
     * Future resolves to an empty Java string.</li>
     *   <li><strong>If the JS expression evaluates to another data type</strong>, then the Java
     * Future resolves to an empty Java String.</li>
     * </ul>
     * The environment uses a single JS global object for all the calls to
     * evaluateJavaScriptAsync(String) and {@link #provideNamedData(String, byte[])} methods.
     * These calls are queued up and are run one at a time in sequence, using the single JS
     * environment for the isolate. The global variables set by one evaluation are visible for
     * later evaluations. This is similar to adding multiple {@code <script>} tags in HTML. The
     * behavior is also similar to
     * {@link android.webkit.WebView#evaluateJavascript(String, android.webkit.ValueCallback)}.
     * <p>
     * If {@link JavaScriptSandbox#isFeatureSupported(String)} for
     * {@link JavaScriptSandbox#JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT} returns {@code
     * false},
     * the size of the expression to be evaluated and the result/error value is limited by the
     * binder transaction limit ({@link android.os.TransactionTooLargeException}). If it returns
     * {@code true}, they are not limited by the binder transaction limit but are bound by
     * {@link IsolateStartupParameters#setMaxEvaluationReturnSizeBytes(int)} with a default size
     * of {@link IsolateStartupParameters#DEFAULT_MAX_EVALUATION_RETURN_SIZE_BYTES}.
     * <p>
     * Do not use this method to transfer raw binary data. Scripts or results containing unpaired
     * surrogate code units are not supported.
     *
     * @param code JavaScript code to evaluate. The script should return a JavaScript String or,
     *             alternatively, a Promise that will resolve to a String if
     *             {@link JavaScriptSandbox#JS_FEATURE_PROMISE_RETURN} is supported.
     * @return a Future that evaluates to the result String of the evaluation or an exception (see
     * {@link JavaScriptException} and subclasses) if there is an error
     */
    @NonNull
    public ListenableFuture<String> evaluateJavaScriptAsync(@NonNull String code) {
        Objects.requireNonNull(code);
        synchronized (mLock) {
            return mIsolateState.evaluateJavaScriptAsync(code);
        }
    }

    /**
     * Reads and evaluates the JavaScript code in the file described by the given
     * AssetFileDescriptor.
     * <p>
     * Please refer to the documentation of {@link #evaluateJavaScriptAsync(String)} as the
     * behavior of this method is similar other than for the input type.
     * <p>
     * This API exposes the underlying file to the service. In case the service process is
     * compromised for unforeseen reasons, it might be able to read from the {@code
     * AssetFileDescriptor} beyond the given length and offset.  This API does <strong>not
     * </strong> close the given {@code AssetFileDescriptor}.
     * <p>
     * <strong>Note: The underlying file data must be UTF-8 encoded.</strong>
     * <p>
     * This overload is useful when the source of the data is easily readable as an
     * {@code AssetFileDescriptor}, e.g. an asset or raw resource.
     *
     * @param afd an {@code AssetFileDescriptor} for a file containing UTF-8 encoded JavaScript
     *            code to be evaluated
     * @return a Future that evaluates to the result String of the evaluation or an exception (see
     * {@link JavaScriptException} and subclasses) if there is an error
     */
    @SuppressWarnings("NullAway")
    @NonNull
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_EVALUATE_FROM_FD,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public ListenableFuture<String> evaluateJavaScriptAsync(@NonNull AssetFileDescriptor afd) {
        Objects.requireNonNull(afd);
        synchronized (mLock) {
            return mIsolateState.evaluateJavaScriptAsync(afd);
        }
    }

    /**
     * Reads and evaluates the JavaScript code in the file described by the given
     * {@code ParcelFileDescriptor}.
     * <p>
     * Please refer to the documentation of {@link #evaluateJavaScriptAsync(String)} as the
     * behavior of this method is similar other than for the input type.
     * <p>
     * This API exposes the underlying file to the service. In case the service process is
     * compromised for unforeseen reasons, it might be able to read from the {@code
     * ParcelFileDescriptor} beyond the given length and offset. This API does <strong>not
     * </strong> close the given {@code ParcelFileDescriptor}.
     * <p>
     * <strong>Note: The underlying file data must be UTF-8 encoded.</strong>
     * <p>
     * This overload is useful when the source of the data is easily readable as a
     * {@code ParcelFileDescriptor}, e.g. a file from shared memory or the app's data directory.
     *
     * @param pfd a {@code ParcelFileDescriptor} for a file containing UTF-8 encoded JavaScript
     *            code that is evaluated
     * @return a Future that evaluates to the result String of the evaluation or an exception (see
     * {@link JavaScriptException} and subclasses) if there is an error
     */
    @SuppressWarnings("NullAway")
    @NonNull
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_EVALUATE_FROM_FD,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public ListenableFuture<String> evaluateJavaScriptAsync(@NonNull ParcelFileDescriptor pfd) {
        Objects.requireNonNull(pfd);
        synchronized (mLock) {
            return mIsolateState.evaluateJavaScriptAsync(pfd);
        }
    }

    /**
     * Closes the {@link JavaScriptIsolate} object and renders it unusable.
     * <p>
     * Once closed, no more method calls should be made. Pending evaluations will reject with
     * an {@link IsolateTerminatedException} immediately.
     * <p>
     * If {@link JavaScriptSandbox#isFeatureSupported(String)} is {@code true} for {@link
     * JavaScriptSandbox#JS_FEATURE_ISOLATE_TERMINATION}, then any pending evaluations are
     * terminated. If it is {@code false}, the isolate will not get cleaned
     * up until the pending evaluations have run to completion and will consume resources until
     * then.
     * <p>
     * Closing an isolate via this method does not wait on the isolate to clean up. Resources
     * held by the isolate may remain in use for a duration after this method returns.
     */
    @Override
    public void close() {
        closeWithDescription("isolate closed");
    }

    void closeWithDescription(@NonNull String description) {
        synchronized (mLock) {
            mIsolateState.close();
            mIsolateState = new IsolateClosedState(description);
        }
        // Do not hold mLock whilst calling into JavaScriptSandbox, as JavaScriptSandbox also has
        // its own lock and may want to call into JavaScriptIsolate from another thread.
        mJsSandbox.removeFromIsolateSet(this);
        mGuard.close();
    }

    /**
     * Provides a byte array for consumption from the JavaScript environment.
     * <p>
     * This method provides an efficient way to pass in data from Java into the JavaScript
     * environment which can be referred to from JavaScript. This is more efficient than including
     * data in the JS expression, and allows large data to be sent.
     * <p>
     * This data can be consumed in the JS environment using {@code
     * android.consumeNamedDataAsArrayBuffer(String)} by referring to the data with the name that
     * was used when calling this method. This is a one-time transfer and the calls should be
     * paired.
     * <p>
     * A single name can only be used once in a particular {@link JavaScriptIsolate}.
     * Clients can generate unique names for each call if they
     * need to use this method multiple times. The same name should be included into the JS code.
     * <p>
     * This API can be used to pass a WASM module into the JS
     * environment for compilation if {@link JavaScriptSandbox#isFeatureSupported(String)} returns
     * {@code true} for {@link JavaScriptSandbox#JS_FEATURE_WASM_COMPILATION}.
     * <br>
     * In Java,
     * <pre>
     *     jsIsolate.provideNamedData("id-1", byteArray);
     * </pre>
     * In JS,
     * <pre>
     *     android.consumeNamedDataAsArrayBuffer("id-1").then((value) => {
     *       return WebAssembly.compile(value).then((module) => {
     *          ...
     *       });
     *     });
     * </pre>
     * <p>
     * The environment uses a single JS global object for all the calls to {@link
     * #evaluateJavaScriptAsync(String)} and provideNamedData(String, byte[]) methods.
     * <p>
     * This method should only be called if
     * {@link JavaScriptSandbox#isFeatureSupported(String)}
     * returns true for {@link JavaScriptSandbox#JS_FEATURE_PROVIDE_CONSUME_ARRAY_BUFFER}.
     *
     * @param name       identifier for the data that is passed. The same identifier should be used
     *                   in the JavaScript environment to refer to the data.
     * @param inputBytes bytes to be passed into the JavaScript environment. This array must not be
     *                   modified until the JavaScript promise returned by
     *                   consumeNamedDataAsArrayBuffer has resolved (or rejected).
     * @throws IllegalStateException if the name has previously been used in the isolate
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_PROVIDE_CONSUME_ARRAY_BUFFER,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public void provideNamedData(@NonNull String name, @NonNull byte[] inputBytes) {
        Objects.requireNonNull(name);
        Objects.requireNonNull(inputBytes);
        synchronized (mLock) {
            mIsolateState.provideNamedData(name, inputBytes);
        }
    }

    @Override
    @SuppressWarnings("GenericException") // super.finalize() throws Throwable
    protected void finalize() throws Throwable {
        try {
            mGuard.warnIfOpen();
            close();
        } finally {
            super.finalize();
        }
    }

    /**
     * Set a JavaScriptConsoleCallback to process console messages from the isolate.
     * <p>
     * Scripts always have access to console APIs, regardless of whether a console callback is
     * set. By default, no console callback is set and calling a console API from JavaScript will do
     * nothing, and will be relatively cheap. Setting a console callback allows console messages to
     * be forwarded to the embedding application, but may negatively impact performance.
     * <p>
     * Note that console APIs may expose messages generated by both JavaScript code and
     * V8/JavaScriptEngine internals.
     * <p>
     * Use caution if using this in production code as it may result in the exposure of debugging
     * information or secrets through logs.
     * <p>
     * When setting a console callback, this method should be called before requesting any
     * evaluations. Calling setConsoleCallback after requesting evaluations may result in those
     * pending evaluations' console messages being dropped or logged to a previous console callback.
     * <p>
     * Note that delayed console messages may continue to be delivered after the isolate has been
     * closed (or has crashed).
     *
     * @param executor the executor for running callback methods
     * @param callback the callback implementing console logging behaviour
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public void setConsoleCallback(@NonNull Executor executor,
            @NonNull JavaScriptConsoleCallback callback) {
        Objects.requireNonNull(executor);
        Objects.requireNonNull(callback);
        synchronized (mLock) {
            mIsolateState.setConsoleCallback(executor, callback);
        }
    }

    /**
     * Set a JavaScriptConsoleCallback to process console messages from the isolate.
     * <p>
     * This is the same as calling {@link #setConsoleCallback(Executor, JavaScriptConsoleCallback)}
     * using the main executor of the context used to create the {@link JavaScriptSandbox} object.
     *
     * @param callback the callback implementing console logging behaviour
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public void setConsoleCallback(@NonNull JavaScriptConsoleCallback callback) {
        Objects.requireNonNull(callback);
        synchronized (mLock) {
            mIsolateState.setConsoleCallback(mJsSandbox.getMainExecutor(),
                    callback);
        }
    }

    /**
     * Clear any JavaScriptConsoleCallback set via {@link #setConsoleCallback}.
     * <p>
     * Clearing a callback is not guaranteed to take effect for any already pending evaluations.
     */
    @RequiresFeature(name = JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING,
            enforcement = "androidx.javascriptengine.JavaScriptSandbox#isFeatureSupported")
    public void clearConsoleCallback() {
        synchronized (mLock) {
            mIsolateState.clearConsoleCallback();
        }
    }

    /**
     * Add a callback to listen for isolate crashes.
     * <p>
     * There is no guaranteed order to when these callbacks are triggered and unfinished
     * evaluations' futures are rejected.
     * <p>
     * Registering a callback after the isolate has crashed will result in it being executed
     * immediately on the supplied executor with the isolate's {@link TerminationInfo} as an
     * argument.
     * <p>
     * Closing an isolate via {@link #close()} is not considered a crash, even if there are
     * unresolved evaluations, and will not trigger termination callbacks.
     *
     * @param executor the executor with which to run callback
     * @param callback the consumer to be called with TerminationInfo when a crash occurs
     * @throws IllegalStateException if the callback is already registered (using any executor)
     */
    @SuppressLint("RegistrationName")
    public void addOnTerminatedCallback(@NonNull Executor executor,
            @NonNull Consumer<TerminationInfo> callback) {
        Objects.requireNonNull(executor);
        Objects.requireNonNull(callback);
        synchronized (mLock) {
            mIsolateState.addOnTerminatedCallback(executor, callback);
        }
    }

    /**
     * Add a callback to listen for isolate crashes.
     * <p>
     * This is the same as calling {@link #addOnTerminatedCallback(Executor, Consumer)} using the
     * main executor of the context used to create the {@link JavaScriptSandbox} object.
     *
     * @param callback the consumer to be called with TerminationInfo when a crash occurs
     * @throws IllegalStateException if the callback is already registered (using any executor)
     */
    @SuppressLint("RegistrationName")
    public void addOnTerminatedCallback(@NonNull Consumer<TerminationInfo> callback) {
        addOnTerminatedCallback(mJsSandbox.getMainExecutor(), callback);
    }

    /**
     * Remove a callback previously registered with addOnTerminatedCallback.
     *
     * @param callback the callback to unregister, if currently registered
     */
    @SuppressLint("RegistrationName")
    public void removeOnTerminatedCallback(@NonNull Consumer<TerminationInfo> callback) {
        Objects.requireNonNull(callback);
        synchronized (mLock) {
            mIsolateState.removeOnTerminatedCallback(callback);
        }
    }
}