lib_web_extension.js

// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The SFC licenses this file
// to you 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.

'use strict'

const fs = require('node:fs/promises')
const path = require('node:path')
const command = require('./command')
const error = require('./error')
const Symbols = require('./symbols')
const { Zip } = require('../io/zip')

/**
 * A browser extension installed via {@link ./webdriver.WebDriver#installWebExtension}.
 * Wraps the identifier the browser assigned; pass it to
 * {@link ./webdriver.WebDriver#uninstallWebExtension}.
 */
class WebExtension {
  /**
   * @param {string} id identifier assigned to the extension by the browser.
   * @package
   */
  constructor(id) {
    /** @const {string} identifier assigned to the extension by the browser. */
    this.id = id
    // A public field, frozen, rather than a private field behind a getter, so logging an
    // extension shows its id instead of an empty `WebExtension {}`.
    Object.freeze(this)
  }
}

/**
 * @param {!./webdriver.WebDriver} driver
 * @return {!Promise<boolean>} whether the session was created with BiDi enabled.
 */
async function isBidiEnabled(driver) {
  const caps = await driver.getCapabilities()
  return Boolean(caps.get('webSocketUrl'))
}

/**
 * @param {string} method the driver method that needs BiDi.
 * @return {!error.UnsupportedOperationError}
 */
function bidiRequired(method) {
  return new error.UnsupportedOperationError(
    `${method} requires BiDi; enable it with options.enableBidi() before creating the session`,
  )
}

/**
 * Checks the install options against the ones this browser honors, so an option it would drop
 * (another browser's, or a misspelled one) fails instead of being silently ignored.
 * @param {*} options the options passed to installWebExtension.
 * @param {!Array<string>} supported the boolean options this browser honors.
 * @param {string} browser the browser name, for the error message.
 * @return {!Object<string, boolean>} the options that were set.
 * @throws {error.InvalidArgumentError} on an unsupported option or a non-boolean value.
 */
function checkInstallOptions(options, supported, browser) {
  if (options === undefined) {
    return {}
  }
  if (typeof options !== 'object' || options === null || Array.isArray(options)) {
    throw new error.InvalidArgumentError('installWebExtension options must be an object')
  }
  const set = {}
  for (const [key, value] of Object.entries(options)) {
    if (value === undefined) continue
    if (!supported.includes(key)) {
      const allowed = supported.length ? `supports only ${supported.join(', ')}` : 'supports no options'
      throw new error.InvalidArgumentError(`installWebExtension on ${browser} ${allowed}; got "${key}"`)
    }
    if (typeof value !== 'boolean') {
      throw new error.InvalidArgumentError(`installWebExtension option "${key}" must be a boolean`)
    }
    set[key] = value
  }
  return set
}

/**
 * @param {*} extension the value passed to uninstallWebExtension.
 * @return {string} the wrapped id.
 * @throws {error.InvalidArgumentError} if `extension` is not a {@link WebExtension}.
 */
function extensionId(extension) {
  if (!(extension instanceof WebExtension)) {
    throw new error.InvalidArgumentError(
      'uninstallWebExtension expects the WebExtension returned by installWebExtension',
    )
  }
  return extension.id
}

/**
 * The extension as base64 bytes: a directory is zipped, a file read, and anything else is
 * taken to already be base64-encoded.
 * @param {string} extension
 * @return {!Promise<string>}
 */
async function encodeExtension(extension) {
  checkExtensionArg(extension)
  const stats = await statOrNull(extension)
  if (stats?.isDirectory()) {
    const zip = new Zip()
    await zip.addDir(extension)
    return (await zip.toBuffer('DEFLATE')).toString('base64')
  }
  if (stats?.isFile()) {
    return (await fs.readFile(extension)).toString('base64')
  }
  return asBase64(extension)
}

/**
 * The BiDi `webExtension.ExtensionData` for `extension`. A directory only resolves on the
 * machine running the browser, so unless the session runs on a driver service this client
 * started (see {@link Symbols.localDriverService}) it is uploaded first; an archive or base64
 * bytes travel inline.
 * @param {!./webdriver.WebDriver} driver
 * @param {string} extension
 * @return {!Promise<import('../bidi/generated/webextension').WebExtensionExtensionData>}
 */
async function extensionData(driver, extension) {
  checkExtensionArg(extension)
  const stats = await statOrNull(extension)
  if (stats?.isDirectory()) {
    const dir = path.resolve(extension)
    const local = driver.getExecutor()[Symbols.localDriverService] === true
    return { type: 'path', path: local ? dir : await uploadDirectory(driver, dir) }
  }
  return { type: 'base64', value: await encodeExtension(extension) }
}

/**
 * Uploads `dir` to the remote end and returns the path it was unpacked to there.
 * @param {!./webdriver.WebDriver} driver
 * @param {string} dir absolute path of the directory.
 * @return {!Promise<string>}
 */
async function uploadDirectory(driver, dir) {
  // The endpoint returns the path of the archive's single top-level entry, so keep the
  // directory itself as that entry rather than flattening its contents.
  const zip = new Zip()
  await zip.addDir(dir, path.basename(dir))
  const encoded = (await zip.toBuffer('DEFLATE')).toString('base64')
  try {
    return await driver.execute(new command.Command(command.Name.UPLOAD_FILE).setParameter('file', encoded))
  } catch (err) {
    if (!isUnsupportedCommand(err)) {
      throw err
    }
    const unsupported = new error.UnsupportedOperationError(
      'Could not upload the extension directory to the remote end. Installing an unpacked ' +
        'extension over a remote session needs a remote end that accepts file uploads, such as ' +
        'the Selenium Grid.',
    )
    unsupported.cause = err
    throw unsupported
  }
}

function isUnsupportedCommand(err) {
  return (
    err instanceof error.UnknownCommandError ||
    err instanceof error.UnknownMethodError ||
    err instanceof error.UnsupportedOperationError ||
    // geckodriver answers an unknown endpoint with a bare 405, which decodes to no specific type.
    (err instanceof error.WebDriverError && err.message === 'HTTP method not allowed')
  )
}

async function statOrNull(file) {
  try {
    return await fs.stat(file)
  } catch (err) {
    if (err.code === 'ENOENT' || err.code === 'ENAMETOOLONG') {
      return null
    }
    throw err
  }
}

function checkExtensionArg(extension) {
  if (typeof extension !== 'string' || extension === '') {
    throw new error.InvalidArgumentError(
      `installWebExtension expects a path or a base64 string, got ${extension === '' ? 'an empty string' : typeof extension}`,
    )
  }
}

// Leading bytes of a packed extension: a zip (.xpi, .zip) or a Chrome .crx.
const ARCHIVE_SIGNATURES = [Buffer.from('PK\x03\x04', 'latin1'), Buffer.from('Cr24', 'latin1')]

/**
 * `value` itself, if it is base64 of a packed extension. Checking the decoded bytes, not just the
 * alphabet, matters: a mistyped relative path such as `extensions/myext` is valid base64 too, and
 * sending it would surface as an unrelated error from the browser.
 * @param {string} value
 * @return {string}
 * @throws {error.InvalidArgumentError} if it is neither.
 */
function asBase64(value) {
  const bytes = /^[A-Za-z0-9+/]+={0,2}$/.test(value) ? Buffer.from(value, 'base64') : null
  if (!bytes || !ARCHIVE_SIGNATURES.some((signature) => bytes.subarray(0, signature.length).equals(signature))) {
    throw new error.InvalidArgumentError(
      `No such file or directory, and not base64 of a packed extension (.xpi/.zip/.crx): ${truncate(value)}`,
    )
  }
  return value
}

function truncate(value, max = 80) {
  return value.length > max ? `${value.slice(0, max)}...` : value
}

module.exports = {
  WebExtension,
  // Package-internal helpers shared by WebDriver and firefox.Driver; not re-exported from index.js.
  bidiRequired,
  checkInstallOptions,
  encodeExtension,
  extensionData,
  extensionId,
  isBidiEnabled,
}