UNPKG

appium-adb

Version:

Android Debug Bridge interface

452 lines (429 loc) 14.9 kB
import _ from 'lodash'; import _fs from 'node:fs'; import {exec, type ExecError} from 'teen_process'; import path from 'node:path'; import {log} from '../logger'; import {tempDir, system, mkdirp, fs, util, zip} from '@appium/support'; import {LRUCache} from 'lru-cache'; import {getJavaForOs, getJavaHome, APKS_EXTENSION, getResourcePath} from '../helpers'; import type {ADB} from '../adb'; import type {StringRecord, SignedAppCacheValue, CertCheckOptions, KeystoreHash} from './types'; const DEFAULT_PRIVATE_KEY = path.join('keys', 'testkey.pk8'); const DEFAULT_CERTIFICATE = path.join('keys', 'testkey.x509.pem'); const BUNDLETOOL_TUTORIAL = 'https://developer.android.com/studio/command-line/bundletool'; const APKSIGNER_VERIFY_FAIL = 'DOES NOT VERIFY'; const SHA1 = 'sha1'; const SHA256 = 'sha256'; const SHA512 = 'sha512'; const MD5 = 'md5'; const DEFAULT_CERT_HASH: KeystoreHash = { [SHA256]: 'a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc', }; const JAVA_PROPS_INIT_ERROR = 'java.lang.Error: Properties init'; const SIGNED_APPS_CACHE = new LRUCache<string, SignedAppCacheValue>({ max: 30, }); /** * Execute apksigner utility with given arguments. * * @param args - The list of tool arguments. * @returns - Command stdout * @throws If apksigner binary is not present on the local file system * or the return code is not equal to zero. */ export async function executeApksigner(this: ADB, args: string[]): Promise<string> { const apkSignerJar = await getApksignerForOs.bind(this)(); const fullCmd = [await getJavaForOs(), '-Xmx1024M', '-Xss1m', '-jar', apkSignerJar, ...args]; log.debug(`Starting apksigner: ${util.quote(fullCmd)}`); // It is necessary to specify CWD explicitly; see https://github.com/appium/appium/issues/14724#issuecomment-737446715 const {stdout, stderr} = await exec(fullCmd[0], fullCmd.slice(1), { cwd: path.dirname(apkSignerJar), }); for (const [name, stream] of [ ['stdout', stdout], ['stderr', stderr], ] as const) { if (!_.trim(stream)) { continue; } if (name === 'stdout') { // Make the output less talkative const filteredStream = stream .split('\n') .filter((line) => !line.includes('WARNING:')) .join('\n'); log.debug(`apksigner ${name}: ${filteredStream}`); } else { log.debug(`apksigner ${name}: ${stream}`); } } return stdout; } /** * (Re)sign the given apk file on the local file system with the default certificate. * * @param apk - The full path to the local apk file. * @throws If signing fails. */ export async function signWithDefaultCert(this: ADB, apk: string): Promise<void> { log.debug(`Signing '${apk}' with default cert`); if (!(await fs.exists(apk))) { throw new Error(`${apk} file doesn't exist.`); } const args = [ 'sign', '--key', await getResourcePath(DEFAULT_PRIVATE_KEY), '--cert', await getResourcePath(DEFAULT_CERTIFICATE), apk, ]; try { await this.executeApksigner(args); } catch (e) { const err = e as ExecError; throw new Error( `Could not sign '${apk}' with the default certificate. ` + `Original error: ${err.stderr || err.stdout || err.message}`, ); } } /** * (Re)sign the given apk file on the local file system with a custom certificate. * * @param apk - The full path to the local apk file. * @throws If signing fails. */ export async function signWithCustomCert(this: ADB, apk: string): Promise<void> { log.debug(`Signing '${apk}' with custom cert`); if (!(await fs.exists(this.keystorePath as string))) { throw new Error(`Keystore: ${this.keystorePath} doesn't exist.`); } if (!(await fs.exists(apk))) { throw new Error(`'${apk}' doesn't exist.`); } try { await this.executeApksigner([ 'sign', '--ks', this.keystorePath as string, '--ks-key-alias', this.keyAlias as string, '--ks-pass', `pass:${this.keystorePassword}`, '--key-pass', `pass:${this.keyPassword}`, apk, ]); } catch (err) { const error = err as ExecError; log.warn( `Cannot use apksigner tool for signing. Defaulting to jarsigner. ` + `Original error: ${error.stderr || error.stdout || error.message}`, ); try { if (await unsignApk(apk)) { log.debug(`'${apk}' has been successfully unsigned`); } else { log.debug(`'${apk}' does not need to be unsigned`); } const jarsigner = path.resolve( await getJavaHome(), 'bin', `jarsigner${system.isWindows() ? '.exe' : ''}`, ); const fullCmd: string[] = [ jarsigner, '-sigalg', 'MD5withRSA', '-digestalg', 'SHA1', '-keystore', this.keystorePath as string, '-storepass', this.keystorePassword as string, '-keypass', this.keyPassword as string, apk, this.keyAlias as string, ]; log.debug(`Starting jarsigner: ${util.quote(fullCmd)}`); await exec(fullCmd[0], fullCmd.slice(1)); } catch (e) { const execErr = e as ExecError; throw new Error( `Could not sign with custom certificate. ` + `Original error: ${execErr.stderr || execErr.message}`, ); } } } /** * (Re)sign the given apk file on the local file system with either * custom or default certificate based on _this.useKeystore_ property value * and Zip-aligns it after signing. * * @param appPath - The full path to the local .apk(s) file. * @throws If signing fails. */ export async function sign(this: ADB, appPath: string): Promise<void> { if (appPath.endsWith(APKS_EXTENSION)) { let message = 'Signing of .apks-files is not supported. '; if (this.useKeystore) { message += 'Consider manual application bundle signing with the custom keystore ' + `like it is described at ${BUNDLETOOL_TUTORIAL}`; } else { message += `Consider manual application bundle signing with the key at '${DEFAULT_PRIVATE_KEY}' ` + `and the certificate at '${DEFAULT_CERTIFICATE}'. Read ${BUNDLETOOL_TUTORIAL} for more details.`; } log.warn(message); return; } // it is necessary to apply zipalign only before signing // if apksigner is used await this.zipAlignApk(appPath); if (this.useKeystore) { await this.signWithCustomCert(appPath); } else { await this.signWithDefaultCert(appPath); } } /** * Perform zip-aligning to the given local apk file. * * @param apk - The full path to the local apk file. * @returns True if the apk has been successfully aligned * or false if the apk has been already aligned. * @throws If zip-align fails. */ export async function zipAlignApk(this: ADB, apk: string): Promise<boolean> { await this.initZipAlign(); try { await exec((this.binaries as StringRecord).zipalign as string, ['-c', '4', apk]); log.debug(`${apk}' is already zip-aligned. Doing nothing`); return false; } catch { log.debug(`'${apk}' is not zip-aligned. Aligning`); } try { await fs.access(apk, _fs.constants.W_OK); } catch { throw new Error( `The file at '${apk}' is not writeable. ` + `Please grant write permissions to this file or to its parent folder '${path.dirname(apk)}' ` + `for the Appium process, so it can zip-align the file`, ); } const alignedApk = await tempDir.path({prefix: 'appium', suffix: '.tmp'}); await mkdirp(path.dirname(alignedApk)); try { await exec((this.binaries as StringRecord).zipalign as string, ['-f', '4', apk, alignedApk]); await fs.mv(alignedApk, apk, {mkdirp: true}); return true; } catch (e) { const err = e as Error; if (await fs.exists(alignedApk)) { await fs.unlink(alignedApk); } throw new Error( `zipAlignApk failed. Original error: ${err.message || (err as ExecError).stderr}`, ); } } /** * Check if the app is already signed with the default Appium certificate. * * @param appPath - The full path to the local .apk(s) file. * @param pkg - The name of application package. * @param opts - Certificate checking options * @returns True if given application is already signed. */ export async function checkApkCert( this: ADB, appPath: string, pkg: string, opts: CertCheckOptions = {}, ): Promise<boolean> { log.debug(`Checking app cert for ${appPath}`); if (!(await fs.exists(appPath))) { log.debug(`'${appPath}' does not exist`); return false; } let actualAppPath = appPath; if (path.extname(appPath) === APKS_EXTENSION) { actualAppPath = await this.extractBaseApk(appPath); } const hashMatches = (apksignerOutput: string, expectedHashes: KeystoreHash): boolean => { for (const [name, value] of _.toPairs(expectedHashes)) { if (value && new RegExp(`digest:\\s+${value}\\b`, 'i').test(apksignerOutput)) { log.debug(`${name} hash did match for '${path.basename(actualAppPath)}'`); return true; } } return false; }; const {requireDefaultCert = true} = opts; const appHash = await fs.hash(actualAppPath); if (SIGNED_APPS_CACHE.has(appHash)) { log.debug(`Using the previously cached signature entry for '${path.basename(actualAppPath)}'`); const cached = SIGNED_APPS_CACHE.get(appHash); if (cached) { const {keystorePath, output, expected} = cached; if ((this.useKeystore && this.keystorePath === keystorePath) || !this.useKeystore) { return (!this.useKeystore && !requireDefaultCert) || hashMatches(output, expected); } } } const expected = this.useKeystore ? await this.getKeystoreHash() : DEFAULT_CERT_HASH; try { await getApksignerForOs.bind(this)(); const output = await this.executeApksigner(['verify', '--print-certs', actualAppPath]); const hasMatch = hashMatches(output, expected); if (hasMatch) { log.info( `'${actualAppPath}' is signed with the ` + `${this.useKeystore ? 'keystore' : 'default'} certificate`, ); } else { log.info( `'${actualAppPath}' is signed with a ` + `non-${this.useKeystore ? 'keystore' : 'default'} certificate`, ); } const isSigned = (!this.useKeystore && !requireDefaultCert) || hasMatch; if (isSigned) { SIGNED_APPS_CACHE.set(appHash, { output, expected, keystorePath: this.keystorePath as string, }); } return isSigned; } catch (err) { const error = err as ExecError; // check if there is no signature if (_.includes(error.stderr, APKSIGNER_VERIFY_FAIL)) { log.info(`'${actualAppPath}' is not signed`); return false; } const errMsg = error.stderr || error.stdout || error.message; if (_.includes(errMsg, JAVA_PROPS_INIT_ERROR)) { // This error pops up randomly and we are not quite sure why. // My guess - a race condition in java vm initialization. // Nevertheless, lets make Appium to believe the file is already signed, // because it would be true for 99% of UIAutomator2-based // tests, where we presign server binaries while publishing their NPM module. // If these are not signed, e.g. in case of Espresso, then the next step(s) // would anyway fail. // See https://github.com/appium/appium/issues/14724 for more details. log.warn(errMsg); log.warn(`Assuming '${actualAppPath}' is already signed and continuing anyway`); return true; } throw new Error( `Cannot verify the signature of '${actualAppPath}'. ` + `Original error: ${errMsg}`, ); } } /** * Retrieve the the hash of the given keystore. * * @returns * @throws If getting keystore hash fails. */ export async function getKeystoreHash(this: ADB): Promise<KeystoreHash> { log.debug(`Getting hash of the '${this.keystorePath}' keystore`); const keytool = path.resolve( await getJavaHome(), 'bin', `keytool${system.isWindows() ? '.exe' : ''}`, ); if (!(await fs.exists(keytool))) { throw new Error(`The keytool utility cannot be found at '${keytool}'`); } const args: string[] = [ '-v', '-list', '-alias', this.keyAlias as string, '-keystore', this.keystorePath as string, '-storepass', this.keystorePassword as string, ]; log.info(`Running '${keytool}' with arguments: ${util.quote(args)}`); try { const {stdout} = await exec(keytool, args); const result: KeystoreHash = {}; for (const hashName of [SHA512, SHA256, SHA1, MD5]) { const hashRe = new RegExp(`^\\s*${hashName}:\\s*([a-f0-9:]+)`, 'mi'); const match = hashRe.exec(stdout); if (!match) { continue; } result[hashName] = match[1].replace(/:/g, '').toLowerCase(); } if (_.isEmpty(result)) { log.debug(stdout); throw new Error('Cannot parse the hash value from the keytool output'); } log.debug(`Keystore hash: ${JSON.stringify(result)}`); return result; } catch (e) { const err = e as ExecError; throw new Error( `Cannot get the hash of '${this.keystorePath}' keystore. ` + `Original error: ${err.stderr || err.message}`, ); } } // #region Private functions /** * Get the absolute path to apksigner tool * * @returns An absolute path to apksigner tool. * @throws If the tool is not present on the local file system. */ export async function getApksignerForOs(this: ADB): Promise<string> { return await this.getBinaryFromSdkRoot('apksigner.jar'); } /** * Unsigns the given apk by removing the * META-INF folder recursively from the archive. * !!! The function overwrites the given apk after successful unsigning !!! * * @param apkPath The path to the apk * @returns `true` if the apk has been successfully * unsigned and overwritten * @throws if there was an error during the unsign operation */ export async function unsignApk(apkPath: string): Promise<boolean> { const tmpRoot = await tempDir.openDir(); const metaInfFolderName = 'META-INF'; try { let hasMetaInf = false; await zip.readEntries(apkPath, ({entry}) => { hasMetaInf = entry.fileName.startsWith(`${metaInfFolderName}/`); // entries iteration stops after `false` is returned return !hasMetaInf; }); if (!hasMetaInf) { return false; } const tmpZipRoot = path.resolve(tmpRoot, 'apk'); await zip.extractAllTo(apkPath, tmpZipRoot); await fs.rimraf(path.resolve(tmpZipRoot, metaInfFolderName)); const tmpResultPath = path.resolve(tmpRoot, path.basename(apkPath)); await zip.toArchive(tmpResultPath, { cwd: tmpZipRoot, }); await fs.unlink(apkPath); await fs.mv(tmpResultPath, apkPath); return true; } finally { await fs.rimraf(tmpRoot); } } // #endregion