From c82ca4f16e999bec64fd777f80bc5952813397ce Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Mon, 16 Feb 2026 13:23:13 +0100 Subject: [PATCH] feat: add validate-elf-alignment command --- .changeset/weak-spoons-peel.md | 6 + .../__tests__/validateElfAlignment.test.ts | 309 ++++++++++++++++++ .../commands/validateElfAlignment/command.ts | 33 ++ .../validateElfAlignment.ts | 214 ++++++++++++ .../src/lib/platformAndroid.ts | 2 + website/src/docs/cli/introduction.md | 29 +- 6 files changed, 588 insertions(+), 5 deletions(-) create mode 100644 .changeset/weak-spoons-peel.md create mode 100644 packages/platform-android/src/lib/commands/validateElfAlignment/__tests__/validateElfAlignment.test.ts create mode 100644 packages/platform-android/src/lib/commands/validateElfAlignment/command.ts create mode 100644 packages/platform-android/src/lib/commands/validateElfAlignment/validateElfAlignment.ts diff --git a/.changeset/weak-spoons-peel.md b/.changeset/weak-spoons-peel.md new file mode 100644 index 000000000..07906275d --- /dev/null +++ b/.changeset/weak-spoons-peel.md @@ -0,0 +1,6 @@ +--- +'@rock-js/platform-android': patch +'rock-docs': patch +--- + +feat: add android command validate-elf-alignment diff --git a/packages/platform-android/src/lib/commands/validateElfAlignment/__tests__/validateElfAlignment.test.ts b/packages/platform-android/src/lib/commands/validateElfAlignment/__tests__/validateElfAlignment.test.ts new file mode 100644 index 000000000..b97a9401e --- /dev/null +++ b/packages/platform-android/src/lib/commands/validateElfAlignment/__tests__/validateElfAlignment.test.ts @@ -0,0 +1,309 @@ +import type { Dirent, PathLike } from 'node:fs'; +import fs from 'node:fs'; +import type { PluginApi } from '@rock-js/config'; +import { logger, outro, RockError, spawn } from '@rock-js/tools'; +import type { Mock } from 'vitest'; +import { test, vi } from 'vitest'; +import { registerValidateElfAlignmentCommand } from '../command.js'; +import * as validateElfAlignmentModule from '../validateElfAlignment.js'; +import { ELF_ALIGNMENT_REGEX, validateElfAlignment } from '../validateElfAlignment.js'; + +vi.mock('../../../paths.js', () => ({ + findAndroidBuildTool: vi.fn(), + getAndroidBuildToolsPath: vi.fn(() => '/mock/sdk/build-tools'), +})); + +const { findAndroidBuildTool } = await import('../../../paths.js'); + +const pluginApi = { + registerCommand: vi.fn(), +} as unknown as PluginApi; + +const MOCK_TEMP_DIR = '/tmp/mock_elf_'; + +const OBJDUMP_ALIGNED = [ + ' LOAD off 0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**14', + ' LOAD off 0x0000000000004000 vaddr 0x0000000000004000 paddr 0x0000000000004000 align 2**14', +].join('\n'); + +const OBJDUMP_UNALIGNED = [ + ' LOAD off 0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**12', + ' LOAD off 0x0000000000001000 vaddr 0x0000000000001000 paddr 0x0000000000001000 align 2**12', +].join('\n'); + +function makeDirent(name: string, isDir: boolean): Dirent { + return { + name, + isDirectory: () => isDir, + isFile: () => !isDir, + isBlockDevice: () => false, + isCharacterDevice: () => false, + isFIFO: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + parentPath: '', + path: '', + }; +} + +function setupExtractedLibs(structure: Record) { + vi.spyOn(fs.promises, 'readdir').mockImplementation( + ((dirPath: PathLike) => { + const dir = dirPath.toString(); + + if (dir === MOCK_TEMP_DIR) { + return Promise.resolve([makeDirent('lib', true)]); + } + + if (dir === `${MOCK_TEMP_DIR}/lib`) { + const abis = Object.keys(structure).map((key) => + key.replace('lib/', ''), + ); + return Promise.resolve(abis.map((abi) => makeDirent(abi, true))); + } + + for (const [abiPath, files] of Object.entries(structure)) { + const abi = abiPath.replace('lib/', ''); + if (dir === `${MOCK_TEMP_DIR}/lib/${abi}`) { + return Promise.resolve(files.map((f) => makeDirent(f, false))); + } + } + + return Promise.resolve([]); + }) as never, + ); +} + +function mockSpawnForLibs( + opts: + | { alignment: string; alignmentByPath?: never } + | { alignmentByPath: Record; alignment?: never }, +) { + (spawn as Mock).mockImplementation((file: string, args: string[]) => { + if (file === 'unzip') { + return Promise.resolve({ output: '' }); + } + + if (file === 'file') { + return Promise.resolve({ + output: `${args[0]}: ELF 64-bit LSB shared object`, + }); + } + + if (file === 'objdump') { + const filePath = args[1] ?? ''; + if (opts.alignmentByPath) { + for (const [key, value] of Object.entries(opts.alignmentByPath)) { + if (filePath.includes(key)) { + return Promise.resolve({ output: value }); + } + } + } + return Promise.resolve({ + output: opts.alignment ?? OBJDUMP_ALIGNED, + }); + } + + return Promise.resolve({ output: '' }); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.mocked(findAndroidBuildTool).mockReturnValue(null); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.spyOn(fs.promises, 'mkdtemp').mockResolvedValue(MOCK_TEMP_DIR); + vi.spyOn(fs.promises, 'rm').mockResolvedValue(); +}); + +// --- Command registration tests --- + +test('registers validate-elf-alignment command metadata', () => { + registerValidateElfAlignmentCommand(pluginApi); + + const [command] = vi.mocked(pluginApi.registerCommand).mock.calls[0]; + + expect(command.name).toBe('validate-elf-alignment'); + expect(command.args).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'binaryPath' })]), + ); +}); + +test('action passes binary path to validateElfAlignment', async () => { + const spy = vi + .spyOn(validateElfAlignmentModule, 'validateElfAlignment') + .mockResolvedValue(); + registerValidateElfAlignmentCommand(pluginApi); + const [command] = vi.mocked(pluginApi.registerCommand).mock.calls[0]; + + await command.action('/tmp/app.apk'); + + expect(spy).toHaveBeenCalledWith('/tmp/app.apk'); + expect(outro).toHaveBeenCalledWith('Success 🎉.'); +}); + +test('action throws when APK path is missing', async () => { + registerValidateElfAlignmentCommand(pluginApi); + const [command] = vi.mocked(pluginApi.registerCommand).mock.calls[0]; + + await expect( + command.action(undefined), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RockError: Missing APK path. Provide it as an argument.]`, + ); +}); + +test('action throws for non-APK file extension', async () => { + registerValidateElfAlignmentCommand(pluginApi); + const [command] = vi.mocked(pluginApi.registerCommand).mock.calls[0]; + + await expect( + command.action('/path/to/app.aab'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RockError: Expected an .apk file, got ".aab".]`, + ); +}); + +// --- ELF alignment regex tests --- + +test.each([ + ['2**14', true], + ['2**15', true], + ['2**16', true], + ['2**19', true], + ['2**20', true], + ['2**99', true], + ['2**100', true], + ['2**12', false], + ['2**13', false], + ['2**0', false], + ['2**1', false], + ['2**9', false], + ['2**10', false], +])('ELF_ALIGNMENT_REGEX matches %s → %s', (value, expected) => { + expect(ELF_ALIGNMENT_REGEX.test(value)).toBe(expected); +}); + +// --- validateElfAlignment internal tests --- + +test('validateElfAlignment throws when APK not found', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect( + validateElfAlignment('/missing/app.apk'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RockError: APK not found "/missing/app.apk".]`, + ); +}); + +test('validateElfAlignment handles APK with no native libs (unzip exit code 11)', async () => { + (spawn as Mock).mockRejectedValue({ exitCode: 11 }); + + await validateElfAlignment('/path/to/app.apk'); + + expect(logger.info).toHaveBeenCalledWith( + 'No native shared libraries found in APK. Skipping ELF alignment check.', + ); + expect(fs.promises.rm).toHaveBeenCalledWith(MOCK_TEMP_DIR, { + recursive: true, + force: true, + }); +}); + +test('validateElfAlignment passes when all libs are aligned', async () => { + setupExtractedLibs({ + 'lib/arm64-v8a': ['libfoo.so'], + }); + mockSpawnForLibs({ alignment: OBJDUMP_ALIGNED }); + + await validateElfAlignment('/path/to/app.apk'); + + expect(logger.info).toHaveBeenCalledWith('ELF alignment check passed.'); +}); + +test('validateElfAlignment fails when arm64-v8a lib is unaligned', async () => { + setupExtractedLibs({ + 'lib/arm64-v8a': ['libfoo.so'], + }); + mockSpawnForLibs({ alignment: OBJDUMP_UNALIGNED }); + + await expect( + validateElfAlignment('/path/to/app.apk'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RockError: ELF alignment check failed.]`, + ); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('must be 16KB aligned'), + ); +}); + +test('validateElfAlignment fails when x86_64 lib is unaligned', async () => { + setupExtractedLibs({ + 'lib/x86_64': ['libfoo.so'], + }); + mockSpawnForLibs({ alignment: OBJDUMP_UNALIGNED }); + + await expect( + validateElfAlignment('/path/to/app.apk'), + ).rejects.toThrow(RockError); +}); + +test('validateElfAlignment passes when only 32-bit libs are unaligned', async () => { + setupExtractedLibs({ + 'lib/arm64-v8a': ['libfoo.so'], + 'lib/armeabi-v7a': ['libfoo.so'], + }); + mockSpawnForLibs({ + alignmentByPath: { + arm64: OBJDUMP_ALIGNED, + armeabi: OBJDUMP_UNALIGNED, + }, + }); + + await validateElfAlignment('/path/to/app.apk'); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('1 unaligned libs'), + ); + expect(logger.info).toHaveBeenCalledWith('ELF alignment check passed.'); +}); + +test('validateElfAlignment logs zipalign not found notice when build tool is missing', async () => { + setupExtractedLibs({ 'lib/arm64-v8a': ['libfoo.so'] }); + mockSpawnForLibs({ alignment: OBJDUMP_ALIGNED }); + + await validateElfAlignment('/path/to/app.apk'); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('zipalign'), + ); +}); + +test('validateElfAlignment cleans up temp dir even when error is thrown', async () => { + setupExtractedLibs({ 'lib/arm64-v8a': ['libfoo.so'] }); + mockSpawnForLibs({ alignment: OBJDUMP_UNALIGNED }); + + await expect( + validateElfAlignment('/path/to/app.apk'), + ).rejects.toThrow(); + + expect(fs.promises.rm).toHaveBeenCalledWith(MOCK_TEMP_DIR, { + recursive: true, + force: true, + }); +}); + +test('validateElfAlignment throws when unzip fails with non-11 exit code', async () => { + (spawn as Mock).mockRejectedValue({ + exitCode: 1, + stderr: 'corrupt archive', + }); + + await expect( + validateElfAlignment('/path/to/app.apk'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[RockError: Failed to extract shared libraries from APK: /path/to/app.apk]`, + ); +}); diff --git a/packages/platform-android/src/lib/commands/validateElfAlignment/command.ts b/packages/platform-android/src/lib/commands/validateElfAlignment/command.ts new file mode 100644 index 000000000..381e624a2 --- /dev/null +++ b/packages/platform-android/src/lib/commands/validateElfAlignment/command.ts @@ -0,0 +1,33 @@ +import path from 'node:path'; +import type { PluginApi } from '@rock-js/config'; +import { outro, RockError } from '@rock-js/tools'; +import { validateElfAlignment } from './validateElfAlignment.js'; + +const ARGUMENTS = [ + { + name: 'binaryPath', + description: 'Path to APK file to validate.', + }, +]; + +export function registerValidateElfAlignmentCommand(api: PluginApi) { + api.registerCommand({ + name: 'validate-elf-alignment', + description: 'Validate ELF alignment of shared libraries in an APK.', + args: ARGUMENTS, + action: async (binaryPath: string | undefined) => { + if (!binaryPath) { + throw new RockError( + 'Missing APK path. Provide it as an argument.', + ); + } + if (path.extname(binaryPath).toLowerCase() !== '.apk') { + throw new RockError( + `Expected an .apk file, got "${path.extname(binaryPath) || 'no extension'}".`, + ); + } + await validateElfAlignment(binaryPath); + outro('Success 🎉.'); + }, + }); +} diff --git a/packages/platform-android/src/lib/commands/validateElfAlignment/validateElfAlignment.ts b/packages/platform-android/src/lib/commands/validateElfAlignment/validateElfAlignment.ts new file mode 100644 index 000000000..b49dcece0 --- /dev/null +++ b/packages/platform-android/src/lib/commands/validateElfAlignment/validateElfAlignment.ts @@ -0,0 +1,214 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + color, + logger, + RockError, + spawn, + type SubprocessError, +} from '@rock-js/tools'; +import { findAndroidBuildTool, getAndroidBuildToolsPath } from '../../paths.js'; + +export const ELF_ALIGNMENT_REGEX = /2\*\*(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,})/; + +export async function validateElfAlignment(apkPath: string) { + if (!fs.existsSync(apkPath)) { + throw new RockError(`APK not found "${apkPath}".`); + } + + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), `${path.basename(apkPath, '.apk')}_elf_`), + ); + + try { + logger.log('Checking APK ELF alignment...'); + await runZipAlignCheck(apkPath); + const hasNativeLibs = await extractApkLibs(apkPath, tempDir); + if (!hasNativeLibs) { + logger.info('No native shared libraries found in APK. Skipping ELF alignment check.'); + return; + } + const unalignedLibs = await findUnalignedLibs(tempDir); + + if (unalignedLibs.length > 0) { + logger.info(`Found ${unalignedLibs.length} unaligned libs:`); + for (const lib of unalignedLibs) { + logger.info(` - ${lib}`); + } + } + + const critical = unalignedLibs.filter(isRequiredAlignedAbi); + + if (critical.length > 0) { + logger.warn( + `\nThe following ${critical.length} lib(s) must be 16KB aligned (arm64-v8a/x86_64):`, + ); + for (const lib of critical) { + logger.warn(` - ${lib}`); + } + throw new RockError('ELF alignment check failed.'); + } + + logger.info('ELF alignment check passed.'); + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + +async function runZipAlignCheck(apkPath: string) { + const zipAlignPath = findAndroidBuildTool('zipalign'); + if (!zipAlignPath) { + logger.info( + `NOTICE: "zipalign" not found in Android Build-Tools directory: ${color.bold( + getAndroidBuildToolsPath(), + )}`, + ); + return; + } + + const supportsPageSize = await spawn(zipAlignPath, ['--help'], { + stdio: 'pipe', + }) + .then(({ output }) => output.includes('-P ')) + .catch(() => false); + + if (!supportsPageSize) { + logger.info( + 'NOTICE: Zip alignment check requires build-tools version 35.0.0-rc3 or higher.', + ); + logger.info(' You can install the latest build-tools by running:'); + logger.info(' sdkmanager "build-tools;35.0.0-rc3"'); + return; + } + + try { + const { output } = await spawn( + zipAlignPath, + ['-v', '-c', '-P', '16', '4', apkPath], + { stdio: 'pipe' }, + ); + const filtered = output + .split('\n') + .filter( + (line: string) => + line.includes('lib/arm64-v8a') || + line.includes('lib/x86_64') || + line.includes('Verification'), + ) + .join('\n') + .trim(); + if (filtered) { + logger.log('APK zip-alignment'); + logger.log(filtered); + } + } catch (error) { + const errorMessage = + (error as SubprocessError).stderr || (error as SubprocessError).stdout; + logger.warn(`Zip alignment check failed: ${errorMessage}`.trim()); + } +} + +/** + * Extracts native shared libraries from the APK. + * Returns `false` if the APK contains no native libraries (unzip exit code 11). + * This mirrors Android's check_elf_alignment.sh which ignores unzip failures + * when no lib/ entries exist. + */ +async function extractApkLibs(apkPath: string, tempDir: string) { + try { + await spawn('unzip', [apkPath, 'lib/*', '-d', tempDir], { stdio: 'pipe' }); + return true; + } catch (error) { + // unzip exits with code 11 when no files match the pattern, + // e.g. a pure Kotlin/Java APK with no native libraries. + if ((error as SubprocessError).exitCode === 11) { + return false; + } + throw new RockError( + `Failed to extract shared libraries from APK: ${apkPath}`, + { cause: (error as SubprocessError).stderr }, + ); + } +} + +const REQUIRED_ALIGNED_ABIS = ['arm64-v8a', 'x86_64']; + +async function findUnalignedLibs(rootDir: string) { + const files = await listFiles(rootDir); + const unaligned: string[] = []; + + for (const filePath of files) { + const isElf = await isElfBinary(filePath); + if (!isElf) { + continue; + } + + const alignment = await readElfAlignment(filePath); + if (alignment && ELF_ALIGNMENT_REGEX.test(alignment)) { + logger.debug( + `${path.relative(rootDir, filePath)}: ALIGNED (${alignment})`, + ); + continue; + } + + logger.debug( + `${path.relative(rootDir, filePath)}: UNALIGNED (${alignment || 'unknown'})`, + ); + unaligned.push(path.relative(rootDir, filePath)); + } + + return unaligned; +} + +function isRequiredAlignedAbi(libPath: string) { + return REQUIRED_ALIGNED_ABIS.some((abi) => libPath.startsWith(`lib/${abi}/`)); +} + +async function isElfBinary(filePath: string) { + try { + const { output } = await spawn('file', [filePath], { stdio: 'pipe' }); + return output.includes(': ELF'); + } catch (error) { + throw new RockError(`Failed to inspect file type for "${filePath}".`, { + cause: (error as SubprocessError).stderr, + }); + } +} + +async function readElfAlignment(filePath: string) { + try { + const { output } = await spawn('objdump', ['-p', filePath], { + stdio: 'pipe', + }); + const loadLine = output + .split('\n') + .map((line: string) => line.trim()) + .find((line: string) => line.startsWith('LOAD')); + if (!loadLine) { + return ''; + } + const parts = loadLine.split(/\s+/); + return parts[parts.length - 1] ?? ''; + } catch (error) { + throw new RockError(`Failed to inspect ELF headers for "${filePath}".`, { + cause: (error as SubprocessError).stderr, + }); + } +} + +async function listFiles(dir: string): Promise { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listFiles(fullPath))); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + + return files; +} diff --git a/packages/platform-android/src/lib/platformAndroid.ts b/packages/platform-android/src/lib/platformAndroid.ts index 72be527c4..66c720ef7 100644 --- a/packages/platform-android/src/lib/platformAndroid.ts +++ b/packages/platform-android/src/lib/platformAndroid.ts @@ -5,6 +5,7 @@ import { registerCreateKeystoreCommand } from './commands/generateKeystore.js'; import { getValidProjectConfig } from './commands/getValidProjectConfig.js'; import { registerRunCommand } from './commands/runAndroid/command.js'; import { registerSignCommand } from './commands/signAndroid/command.js'; +import { registerValidateElfAlignmentCommand } from './commands/validateElfAlignment/command.js'; type PluginConfig = AndroidProjectConfig; @@ -13,6 +14,7 @@ export const platformAndroid = (api: PluginApi): PlatformOutput => { registerBuildCommand(api, pluginConfig); registerRunCommand(api, pluginConfig); + registerValidateElfAlignmentCommand(api); registerCreateKeystoreCommand(api, pluginConfig); registerSignCommand(api); diff --git a/website/src/docs/cli/introduction.md b/website/src/docs/cli/introduction.md index 848af0ffc..1d9c728c1 100644 --- a/website/src/docs/cli/introduction.md +++ b/website/src/docs/cli/introduction.md @@ -113,11 +113,12 @@ Platform plugins are configured through the [`platform`](/docs/configuration/ind - `@rock-js/platform-android` – Android platform plugin with the following commands: - | Command | Description | - | :-------------- | :-------------------------------------------------------------- | - | `run:android` | Runs Android app on emulator or device | - | `build:android` | Builds Android app for generic emulator, device or distribution | - | `sign:android` | Signs Android app with keystore | + | Command | Description | + | :------------------------- | :-------------------------------------------------------------- | + | `run:android` | Runs Android app on emulator or device | + | `build:android` | Builds Android app for generic emulator, device or distribution | + | `sign:android` | Signs Android app with keystore | + | `validate-elf-alignment` | Validates ELF alignment of shared libraries in an APK | - `@rock-js/platform-ios` – iOS platform plugin with the following commands: @@ -264,6 +265,24 @@ The `sign:android ` command signs your Android app with a keystore, | `--jsbundle ` | Path to JS bundle to apply before signing | | `--no-hermes` | Don't use Hermes for JS bundle | + +### `rock validate-elf-alignment` Options + +The `validate-elf-alignment` command validates that shared libraries (`.so` files) inside an APK are properly aligned to 16KB page boundaries. Starting with Android 15, the Google Play Store requires 64-bit shared libraries (`arm64-v8a`, `x86_64`) to be aligned to 16KB pages. See [Android documentation on 16KB page sizes](https://developer.android.com/guide/practices/page-sizes#build-app-16kb) for more details. + +This command is based on the [check_elf_alignment.sh](https://cs.android.com/android/platform/superproject/main/+/main:system/extras/tools/check_elf_alignment.sh) script from the Android platform source. + +The command performs two checks: + +1. **Zip alignment check** (optional) — runs `zipalign` to verify APK-level alignment. Requires Android Build-Tools 35.0.0-rc3 or higher. +2. **ELF alignment check** — extracts shared libraries from the APK and inspects each ELF binary's `LOAD` segment alignment using `objdump`. Alignment of `2**14` (16KB) or higher is considered valid. + +All unaligned libraries are listed, and the critical 64-bit ones (`arm64-v8a`/`x86_64`) are highlighted separately. Only unaligned 64-bit libraries cause the check to fail. + +| Argument | Description | +| :----------- | :------------------- | +| `binaryPath` | Path to the APK file | + ## Platform HarmonyOS (experimental) :::warning