Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Properly cache manifests for .ipa bundles containing multiple apps #2394

Merged
merged 10 commits into from
May 7, 2024
165 changes: 93 additions & 72 deletions lib/app-infos-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const MANIFEST_CACHE = new LRUCache({
updateAgeOnHas: true,
});
const MANIFEST_FILE_NAME = 'Info.plist';
const IPA_ROOT_PLIST_PATH_PATTERN = new RegExp(
`^Payload/[^./]+\\.app/${_.escapeRegExp(MANIFEST_FILE_NAME)}$`
);
const MAX_MANIFEST_SIZE = 1024 * 1024; // 1 MiB

export class AppInfosCache {
Expand All @@ -22,130 +25,134 @@ export class AppInfosCache {

/**
*
* @param {string} appPath
* @param {string} bundlePath Full path to the .ipa or .app bundle
* @param {string} propertyName
* @returns {Promise<any>}
*/
async extractManifestProperty (appPath, propertyName) {
const result = (await this.put(appPath))[propertyName];
async extractManifestProperty (bundlePath, propertyName) {
const result = (await this.put(bundlePath))[propertyName];
this.log.debug(`${propertyName}: ${JSON.stringify(result)}`);
return result;
}

/**
*
* @param {string} appPath
* @param {string} bundlePath Full path to the .ipa or .app bundle
* @returns {Promise<string>}
*/
async extractBundleId (appPath) {
return await this.extractManifestProperty(appPath, 'CFBundleIdentifier');
async extractBundleId (bundlePath) {
return await this.extractManifestProperty(bundlePath, 'CFBundleIdentifier');
}

/**
*
* @param {string} appPath
* @param {string} bundlePath Full path to the .ipa or .app bundle
* @returns {Promise<string>}
*/
async extractBundleVersion (appPath) {
return await this.extractManifestProperty(appPath, 'CFBundleVersion');
async extractBundleVersion (bundlePath) {
return await this.extractManifestProperty(bundlePath, 'CFBundleVersion');
}

/**
*
* @param {string} appPath
* @param {string} bundlePath Full path to the .ipa or .app bundle
* @returns {Promise<string[]>}
*/
async extractAppPlatforms (appPath) {
const result = await this.extractManifestProperty(appPath, 'CFBundleSupportedPlatforms');
async extractAppPlatforms (bundlePath) {
const result = await this.extractManifestProperty(bundlePath, 'CFBundleSupportedPlatforms');
if (!Array.isArray(result)) {
throw new Error(`${path.basename(appPath)}': CFBundleSupportedPlatforms is not a valid list`);
throw new Error(`${path.basename(bundlePath)}': CFBundleSupportedPlatforms is not a valid list`);
}
return result;
}

/**
*
* @param {string} appPath
* @param {string} bundlePath Full path to the .ipa or .app bundle
* @returns {Promise<string>}
*/
async extractExecutableName (appPath) {
return await this.extractManifestProperty(appPath, 'CFBundleExecutable');
async extractExecutableName (bundlePath) {
return await this.extractManifestProperty(bundlePath, 'CFBundleExecutable');
}

/**
*
* @param {string} appPath Full path to the .ipa or .app bundle
* @param {string} bundlePath Full path to the .ipa or .app bundle
* @returns {Promise<import('@appium/types').StringRecord>} The payload of the manifest plist
* @throws {Error} If the given app is not a valid bundle
*/
async put (appPath) {
const readPlist = async (/** @type {string} */ plistPath) => {
try {
return await plist.parsePlistFile(plistPath);
} catch (e) {
this.log.debug(e.stack);
throw new Error(`Cannot parse ${MANIFEST_FILE_NAME} of '${appPath}'. Is it a valid application bundle?`);
}
};
async put (bundlePath) {
return (await fs.stat(bundlePath)).isFile()
? await this._putIpa(bundlePath)
: await this._putApp(bundlePath);
}

if ((await fs.stat(appPath)).isFile()) {
/** @type {import('@appium/types').StringRecord|undefined} */
let manifestPayload;
/** @type {Error|undefined} */
let lastError;
try {
await zip.readEntries(appPath, async ({entry, extractEntryTo}) => {
if (!_.endsWith(entry.fileName, `.app/${MANIFEST_FILE_NAME}`)) {
return true;
}
/**
* @param {string} ipaPath Fill path to the .ipa bundle
* @returns {Promise<import('@appium/types').StringRecord>} The payload of the manifest plist
*/
async _putIpa(ipaPath) {
/** @type {import('@appium/types').StringRecord|undefined} */
let manifestPayload;
/** @type {Error|undefined} */
let lastError;
try {
await zip.readEntries(ipaPath, async ({entry, extractEntryTo}) => {
if (!IPA_ROOT_PLIST_PATH_PATTERN.test(entry.fileName)) {
mykola-mokhnach marked this conversation as resolved.
Show resolved Hide resolved
return true;
}

const hash = `${entry.crc32}`;
if (MANIFEST_CACHE.has(hash)) {
manifestPayload = MANIFEST_CACHE.get(hash);
return false;
}
const tmpRoot = await tempDir.openDir();
try {
await extractEntryTo(tmpRoot);
const plistPath = path.resolve(tmpRoot, entry.fileName);
manifestPayload = await readPlist(plistPath);
if (entry.uncompressedSize <= MAX_MANIFEST_SIZE && _.isPlainObject(manifestPayload)) {
this.log.debug(
`Caching the manifest for ${manifestPayload?.CFBundleIdentifier} app ` +
`from an archived source using the key '${hash}'`
);
MANIFEST_CACHE.set(hash, manifestPayload);
}
} catch (e) {
this.log.debug(e.stack);
lastError = e;
} finally {
await fs.rimraf(tmpRoot);
}
const hash = `${entry.crc32}`;
if (MANIFEST_CACHE.has(hash)) {
manifestPayload = MANIFEST_CACHE.get(hash);
return false;
});
} catch (e) {
this.log.debug(e.stack);
throw new Error(`Cannot find ${MANIFEST_FILE_NAME} in '${appPath}'. Is it a valid application bundle?`);
}
if (!manifestPayload) {
let errorMessage = `Cannot extract ${MANIFEST_FILE_NAME} from '${appPath}'. Is it a valid application bundle?`;
if (lastError) {
errorMessage += ` Original error: ${lastError.message}`;
}
throw new Error(errorMessage);
const tmpRoot = await tempDir.openDir();
try {
await extractEntryTo(tmpRoot);
const plistPath = path.resolve(tmpRoot, entry.fileName);
manifestPayload = await this._readPlist(plistPath, ipaPath);
if (_.isPlainObject(manifestPayload) && entry.uncompressedSize <= MAX_MANIFEST_SIZE) {
this.log.debug(
`Caching the manifest '${entry.fileName}' for ${manifestPayload?.CFBundleIdentifier} app ` +
`from the compressed source using the key '${hash}'`
);
MANIFEST_CACHE.set(hash, manifestPayload);
}
} catch (e) {
this.log.debug(e.stack);
lastError = e;
} finally {
await fs.rimraf(tmpRoot);
}
return false;
});
} catch (e) {
this.log.debug(e.stack);
throw new Error(`Cannot find ${MANIFEST_FILE_NAME} in '${ipaPath}'. Is it a valid application bundle?`);
}
if (!manifestPayload) {
let errorMessage = `Cannot extract ${MANIFEST_FILE_NAME} from '${ipaPath}'. Is it a valid application bundle?`;
if (lastError) {
errorMessage += ` Original error: ${lastError.message}`;
}
return manifestPayload;
throw new Error(errorMessage);
}
return manifestPayload;
}

// appPath points to a folder
/**
* @param {string} appPath Fill path to the .app bundle
* @returns {Promise<import('@appium/types').StringRecord>} The payload of the manifest plist
*/
async _putApp(appPath) {
const manifestPath = path.join(appPath, MANIFEST_FILE_NAME);
const hash = await fs.hash(manifestPath);
if (MANIFEST_CACHE.has(hash)) {
return /** @type {import('@appium/types').StringRecord} */ (MANIFEST_CACHE.get(hash));
}
const [payload, stat] = await B.all([
readPlist(manifestPath),
this._readPlist(manifestPath, appPath),
fs.stat(manifestPath),
]);
if (stat.size <= MAX_MANIFEST_SIZE && _.isPlainObject(payload)) {
Expand All @@ -156,4 +163,18 @@ export class AppInfosCache {
}
return payload;
}

/**
* @param {string} plistPath Full path to the plist
* @param {string} bundlePath Full path to .ipa or .app bundle
* @returns {Promise<any>} The payload of the plist file
*/
async _readPlist(plistPath, bundlePath) {
try {
return await plist.parsePlistFile(plistPath);
} catch (e) {
this.log.debug(e.stack);
throw new Error(`Cannot parse ${MANIFEST_FILE_NAME} of '${bundlePath}'. Is it a valid application bundle?`);
}
}
}
Loading