// 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,
}