UNPKG

appstore-cli

Version:

A command-line interface (CLI) to interact with the Apple App Store Connect API.

355 lines (311 loc) 11.6 kB
import fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { execSync } from 'child_process'; import { safeLogger } from '../security/dataHandler.js'; /** * Represents parsed provisioning profile information */ export interface ProvisioningProfileInfo { appIdName: string; applicationIdentifierPrefix: string[]; creationDate: string; platform: string[]; isXcodeManaged?: boolean; name: string; teamIdentifier: string[]; teamName: string; timeToLive: number; uuid: string; version: number; entitlements: any; } /** * Validates a provisioning profile file path */ export class ProvisioningProfileValidator { /** * Validates that a provisioning profile file path is valid * @param filePath The file path to validate * @returns True if valid, false otherwise */ static validateFilePath(filePath: string): boolean { try { // Check if file exists if (!fs.existsSync(filePath)) { safeLogger.error('Provisioning profile file does not exist', { filePath }); return false; } // Check if it's a file (not a directory) const stats = fs.statSync(filePath); if (!stats.isFile()) { safeLogger.error('Provisioning profile path is not a file', { filePath }); return false; } // Check file extension if (!this.hasValidExtension(filePath)) { safeLogger.error('Provisioning profile file has invalid extension. Expected .mobileprovision', { filePath }); return false; } // Check if file is readable if (!this.isReadable(filePath)) { safeLogger.error('Provisioning profile file is not readable. Check file permissions', { filePath }); return false; } safeLogger.debug('Provisioning profile file path is valid', { filePath }); return true; } catch (error) { safeLogger.error('Error validating provisioning profile file path', { filePath, error: (error as Error).message }); return false; } } /** * Checks if a provisioning profile file has a valid extension * @param filePath The file path to check * @returns True if the extension is valid, false otherwise */ static hasValidExtension(filePath: string): boolean { const extension = path.extname(filePath).toLowerCase(); return extension === '.mobileprovision'; } /** * Checks if a file is readable * @param filePath The file path to check * @returns True if the file is readable, false otherwise */ static isReadable(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.R_OK); return true; } catch (error) { return false; } } /** * Gets the file name without extension * @param filePath The file path * @returns The file name without extension */ static getFileNameWithoutExtension(filePath: string): string { const basename = path.basename(filePath); const extension = path.extname(filePath); return basename.slice(0, basename.length - extension.length); } /** * Parses and validates a provisioning profile file content * @param filePath The path to the provisioning profile file * @returns Parsed provisioning profile information or null if invalid */ static parseAndValidate(filePath: string): ProvisioningProfileInfo | null { try { // First validate the file path if (!this.validateFilePath(filePath)) { return null; } // Use the security tool to parse the provisioning profile // This is the standard approach on macOS for parsing .mobileprovision files const command = `security cms -D -i "${filePath}"`; const output = execSync(command, { encoding: 'utf8' }); // Parse the XML plist output const plist = this.parsePlist(output); // Validate required fields if (!this.validateProfileContent(plist)) { safeLogger.error('Provisioning profile content validation failed', { filePath }); return null; } safeLogger.debug('Provisioning profile parsed and validated successfully', { filePath }); return plist as ProvisioningProfileInfo; } catch (error) { safeLogger.error('Error parsing provisioning profile. This may indicate a corrupted file or an issue with the security tool', { filePath, error: (error as Error).message }); return null; } } /** * Parses a plist XML string * @param xmlString The XML plist string * @returns Parsed plist object */ private static parsePlist(xmlString: string): any { try { // For simplicity, we'll use a basic regex approach to extract key-value pairs // In a production environment, we would use a proper plist parser library const plist: any = {}; // Extract key-value pairs using regex const keyRegex = /<key>([^<]+)<\/key>\s*<([^>]+)>([^<]*)<\/\2>/g; let match; while ((match = keyRegex.exec(xmlString)) !== null) { const key = match[1]; const type = match[2]; const value = match[3]; // Convert values based on type if (type === 'string') { plist[key] = value; } else if (type === 'integer') { plist[key] = parseInt(value, 10); } else if (type === 'true') { plist[key] = true; } else if (type === 'false') { plist[key] = false; } else { plist[key] = value; } } // Handle array values const arrayRegex = /<key>([^<]+)<\/key>\s*<array>([\s\S]*?)<\/array>/g; let arrayMatch; while ((arrayMatch = arrayRegex.exec(xmlString)) !== null) { const key = arrayMatch[1]; const arrayContent = arrayMatch[2]; const arrayValues: string[] = []; const stringRegex = /<string>([^<]*)<\/string>/g; let stringMatch; while ((stringMatch = stringRegex.exec(arrayContent)) !== null) { arrayValues.push(stringMatch[1]); } plist[key] = arrayValues; } // Handle dict values (entitlements) const dictRegex = /<key>(Entitlements)<\/key>\s*<dict>([\s\S]*?)<\/dict>/; const dictMatch = xmlString.match(dictRegex); if (dictMatch) { const dictContent = dictMatch[2]; const entitlements: any = {}; const entitlementRegex = /<key>([^<]+)<\/key>\s*<([^>]+)>([^<]*)<\/\2>/g; let entitlementMatch; while ((entitlementMatch = entitlementRegex.exec(dictContent)) !== null) { const key = entitlementMatch[1]; const type = entitlementMatch[2]; const value = entitlementMatch[3]; if (type === 'string') { entitlements[key] = value; } else if (type === 'true') { entitlements[key] = true; } else if (type === 'false') { entitlements[key] = false; } else { entitlements[key] = value; } } plist.Entitlements = entitlements; } return plist; } catch (error) { safeLogger.error('Error parsing plist. The provisioning profile may be corrupted', { error: (error as Error).message }); throw error; } } /** * Validates the content of a parsed provisioning profile * @param profile The parsed provisioning profile * @returns True if valid, false otherwise */ private static validateProfileContent(profile: any): boolean { try { // Check required fields const requiredFields = [ 'AppIDName', 'ApplicationIdentifierPrefix', 'CreationDate', 'Platform', 'Name', 'TeamIdentifier', 'TeamName', 'TimeToLive', 'UUID', 'Version' ]; for (const field of requiredFields) { if (!(field in profile)) { safeLogger.error(`Required field missing in provisioning profile: ${field}`); return false; } } // Validate specific fields if (!Array.isArray(profile.ApplicationIdentifierPrefix) || profile.ApplicationIdentifierPrefix.length === 0) { safeLogger.error('ApplicationIdentifierPrefix must be a non-empty array'); return false; } if (!Array.isArray(profile.Platform) || profile.Platform.length === 0) { safeLogger.error('Platform must be a non-empty array'); return false; } if (!Array.isArray(profile.TeamIdentifier) || profile.TeamIdentifier.length === 0) { safeLogger.error('TeamIdentifier must be a non-empty array'); return false; } if (typeof profile.TimeToLive !== 'number') { safeLogger.error('TimeToLive must be a number'); return false; } if (typeof profile.Version !== 'number') { safeLogger.error('Version must be a number'); return false; } // Check if the profile has expired const creationDate = new Date(profile.CreationDate); const expirationDate = new Date(creationDate.getTime() + profile.TimeToLive * 24 * 60 * 60 * 1000); const now = new Date(); if (expirationDate < now) { safeLogger.error('Provisioning profile has expired. Please download a new profile from the Apple Developer portal'); return false; } return true; } catch (error) { safeLogger.error('Error validating provisioning profile content', { error: (error as Error).message }); return false; } } /** * Extracts basic information from a provisioning profile for display * @param profile The parsed provisioning profile * @returns Basic information about the provisioning profile */ static getBasicInfo(profile: ProvisioningProfileInfo): any { return { name: profile.name, uuid: profile.uuid, teamName: profile.teamName, appIdName: profile.appIdName, creationDate: profile.creationDate, expirationDate: this.calculateExpirationDate(profile), bundleId: profile.entitlements?.['application-identifier'] || 'Unknown' }; } /** * Calculates the expiration date of a provisioning profile * @param profile The provisioning profile * @returns The expiration date as a string */ private static calculateExpirationDate(profile: ProvisioningProfileInfo): string { try { const creationDate = new Date(profile.creationDate); const expirationDate = new Date(creationDate.getTime() + profile.timeToLive * 24 * 60 * 60 * 1000); return expirationDate.toISOString(); } catch (error) { return 'Unknown'; } } /** * Provides troubleshooting guidance for provisioning profile issues * @returns Array of troubleshooting tips */ static getTroubleshootingTips(): string[] { return [ "Ensure the provisioning profile file has a .mobileprovision extension", "Verify the file is not corrupted by opening it with Xcode", "Check that the profile hasn't expired (download a new one from Apple Developer portal if needed)", "Ensure the profile matches your app's bundle identifier", "Verify the profile includes the correct devices (for development profiles)", "Make sure you have the correct team identifier in your profile" ]; } } export default ProvisioningProfileValidator;