chromium/android_webview/js_sandbox/java/src/org/chromium/android_webview/js_sandbox/service/JsSandboxIsolate.java

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.android_webview.js_sandbox.service;

import android.content.res.AssetFileDescriptor;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;

import androidx.javascriptengine.common.Utils;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.android_webview.js_sandbox.common.IJsSandboxConsoleCallback;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolate;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolateCallback;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolateClient;
import org.chromium.android_webview.js_sandbox.common.IJsSandboxIsolateSyncCallback;
import org.chromium.base.Log;

import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;

import javax.annotation.concurrent.GuardedBy;

/** Service that provides methods for Javascript execution. */
@JNINamespace("android_webview")
public class JsSandboxIsolate extends IJsSandboxIsolate.Stub {
    private static final String TAG = "JsSandboxIsolate";
    // mLock must never be held whilst (synchronously) calling back into the client/embedding
    // application, otherwise it's entirely possible for the embedder to then call back into service
    // code (on another thread) and then try to take mLock again and therefore deadlock.
    private final Object mLock = new Object();
    private final JsSandboxService mService;
    private final AtomicReference<IJsSandboxConsoleCallback> mConsoleCallback =
            new AtomicReference<IJsSandboxConsoleCallback>();

    @GuardedBy("mLock")
    private long mJsSandboxIsolate;

    private final IJsSandboxIsolateClient mIsolateClient;

    JsSandboxIsolate(JsSandboxService service) {
        this(service, 0);
    }

    JsSandboxIsolate(JsSandboxService service, long maxHeapSizeBytes) {
        this(service, maxHeapSizeBytes, null);
    }

    JsSandboxIsolate(
            JsSandboxService service,
            long maxHeapSizeBytes,
            IJsSandboxIsolateClient isolateClient) {
        mService = service;
        mIsolateClient = isolateClient;
        mJsSandboxIsolate =
                JsSandboxIsolateJni.get()
                        .createNativeJsSandboxIsolateWrapper(this, maxHeapSizeBytes);
    }

    @Override
    public void evaluateJavascript(String code, IJsSandboxIsolateCallback callback) {
        synchronized (mLock) {
            if (mJsSandboxIsolate == 0) {
                throw new IllegalStateException("evaluateJavascript() called after close()");
            }
            JsSandboxIsolateJni.get()
                    .evaluateJavascript(
                            mJsSandboxIsolate, this, code, new JsSandboxIsolateCallback(callback));
        }
    }

    @Override
    public void evaluateJavascriptWithFd(
            AssetFileDescriptor afd, IJsSandboxIsolateSyncCallback callback) {
        synchronized (mLock) {
            if (mJsSandboxIsolate == 0) {
                throw new IllegalStateException("evaluateJavascript() called after close()");
            }

            Utils.checkAssetFileDescriptor(afd, /* allowUnknownLength= */ true);
            if (afd.getLength() > Integer.MAX_VALUE) {
                throw new IllegalArgumentException(
                        "Evaluation code larger than "
                                + Integer.MAX_VALUE
                                + " bytes not supported");
            }
            JsSandboxIsolateJni.get()
                    .evaluateJavascriptWithFd(
                            mJsSandboxIsolate,
                            this,
                            afd.getParcelFileDescriptor().getFd(),
                            afd.getLength(),
                            afd.getStartOffset(),
                            new JsSandboxIsolateFdCallback(callback),
                            afd.getParcelFileDescriptor());
        }
    }

    @Override
    public void close() {
        synchronized (mLock) {
            if (mJsSandboxIsolate == 0) {
                return;
            }
            JsSandboxIsolateJni.get().destroyNative(mJsSandboxIsolate, this);
            mJsSandboxIsolate = 0;
        }
    }

    @Override
    public boolean provideNamedData(String name, AssetFileDescriptor afd) {
        synchronized (mLock) {
            if (mJsSandboxIsolate == 0) {
                throw new IllegalStateException(
                        "provideNamedData(String, AssetFileDescriptor) called after close()");
            }

            Utils.checkAssetFileDescriptor(afd, /* allowUnknownLength= */ false);
            if (afd.getLength() > Integer.MAX_VALUE) {
                throw new IllegalArgumentException(
                        "Named data larger than " + Integer.MAX_VALUE + " bytes not supported");
            }
            boolean nativeReturn =
                    JsSandboxIsolateJni.get()
                            .provideNamedData(
                                    mJsSandboxIsolate,
                                    this,
                                    name,
                                    afd.getParcelFileDescriptor().detachFd(),
                                    (int) afd.getLength());
            return nativeReturn;
        }
    }

    // Roughly truncate a (Unicode) Java string, avoiding truncation in the middle of a surrogate
    // pair. Note that this is fairly naive and doesn't deal with any additional complexities of
    // Unicode, such as characters composed of multiple code points, modifiers, ...
    //
    // maxCodePoints must be > 0.
    private static String truncateUnicodeString(String original, int maxLength) {
        if (original == null || original.length() <= maxLength) {
            return original;
        }
        if (Character.isHighSurrogate(original.charAt(maxLength - 1))) {
            maxLength--;
        }
        return original.substring(0, maxLength);
    }

    // Called by isolate thread
    @CalledByNative
    public void consoleMessage(
            int contextGroupId,
            int level,
            String message,
            String source,
            int line,
            int column,
            String trace) {
        final IJsSandboxConsoleCallback callback = mConsoleCallback.get();
        if (callback == null) {
            return;
        }
        // Note these are measured in chars (not bytes), so in the worst case the Binder parcel size
        // may be a little larger than 2 * (32768 + 4069 + 16348) = 106496.
        final int messageLimit = 32768;
        final int sourceLimit = 4096;
        final int traceLimit = 16384;
        message = truncateUnicodeString(message, messageLimit);
        source = truncateUnicodeString(source, sourceLimit);
        trace = truncateUnicodeString(trace, traceLimit);
        try {
            callback.consoleMessage(contextGroupId, level, message, source, line, column, trace);
        } catch (RemoteException e) {
            Log.e(TAG, "consoleMessage notification failed", e);
        }
    }

    // Called by isolate thread
    @CalledByNative
    public void consoleClear(int contextGroupId) {
        final IJsSandboxConsoleCallback callback = mConsoleCallback.get();
        if (callback == null) {
            return;
        }
        try {
            callback.consoleClear(contextGroupId);
        } catch (RemoteException e) {
            Log.e(TAG, "consoleClear notification failed", e);
        }
    }

    // Checks for errors thrown by client side while reading the stream and closes the Pfd.
    @CalledByNative
    private static String checkStreamingErrorAndClosePfd(ParcelFileDescriptor pfd) {
        try {
            if (pfd.canDetectErrors()) {
                try {
                    pfd.checkError();
                } catch (IOException e) {
                    // This streaming error would have already been thrown on the client side.
                    return e.toString();
                }
            }
        } finally {
            try {
                pfd.close();
            } catch (IOException e) {
                Log.e(TAG, "could not close Pfd", e);
            }
        }
        // Either Pfd is not associated with a reliablePipe or remote-side has no errors to report
        return null;
    }

    @Override
    public void setConsoleCallback(IJsSandboxConsoleCallback callback) {
        synchronized (mLock) {
            if (mJsSandboxIsolate == 0) {
                throw new IllegalStateException("setConsoleCallback() called after close()");
            }
            mConsoleCallback.set(callback);
            JsSandboxIsolateJni.get().setConsoleEnabled(mJsSandboxIsolate, this, callback != null);
        }
    }

    // Notify the client side that the isolate should be terminated.
    //
    // Returns true if the client supports and received the onTerminated notification. (It is OK to
    // call this method regardless of whether the client supports the notification.)
    //
    // The service must ensure no other Binder calls (related to this isolate) are made back to the
    // client if this method returns true.
    @CalledByNative
    public boolean sendTermination(int status, String message) {
        if (mIsolateClient == null) {
            return false;
        }
        try {
            final String binderFriendlyMessage = truncateUnicodeString(message, 32768);
            mIsolateClient.onTerminated(status, binderFriendlyMessage);
            return true;
        } catch (RemoteException e) {
            // The client theoretically supports notifications, but probably didn't get it.
            // Ignoring this failure might cause the client to hang forever, so kill the whole
            // sandbox with an exception, which the client shouldn't ignore.
            throw new RuntimeException(e);
        }
    }

    public static void initializeEnvironment() {
        JsSandboxIsolateJni.get().initializeEnvironment();
    }

    @NativeMethods
    public interface Natives {
        long createNativeJsSandboxIsolateWrapper(
                JsSandboxIsolate jsSandboxIsolate, long maxHeapSizeBytes);

        void initializeEnvironment();

        // The calling code must not call any methods after it called destroyNative().
        void destroyNative(long nativeJsSandboxIsolate, JsSandboxIsolate caller);

        boolean evaluateJavascript(
                long nativeJsSandboxIsolate,
                JsSandboxIsolate caller,
                String script,
                JsSandboxIsolateCallback callback);

        boolean evaluateJavascriptWithFd(
                long nativeJsSandboxIsolate,
                JsSandboxIsolate caller,
                int fd,
                long length,
                long offset,
                JsSandboxIsolateFdCallback callback,
                ParcelFileDescriptor pfd);

        boolean provideNamedData(
                long nativeJsSandboxIsolate,
                JsSandboxIsolate caller,
                String name,
                int fd,
                int length);

        void setConsoleEnabled(
                long nativeJsSandboxIsolate, JsSandboxIsolate caller, boolean enable);
    }
}