Skip to content

Commit 63c5ef7

Browse files
committed
feat: add validate-elf-alignment command
1 parent df12e67 commit 63c5ef7

6 files changed

Lines changed: 585 additions & 5 deletions

File tree

.changeset/weak-spoons-peel.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rock-js/platform-android': patch
3+
'rock-docs': patch
4+
---
5+
6+
feat: add android command validate-elf-alignment
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import type { Dirent, PathLike } from 'node:fs';
2+
import fs from 'node:fs';
3+
import type { PluginApi } from '@rock-js/config';
4+
import { logger, outro, RockError, spawn } from '@rock-js/tools';
5+
import type { Mock } from 'vitest';
6+
import { test, vi } from 'vitest';
7+
import { registerValidateElfAlignmentCommand } from '../command.js';
8+
import * as validateElfAlignmentModule from '../validateElfAlignment.js';
9+
import { ELF_ALIGNMENT_REGEX, validateElfAlignment } from '../validateElfAlignment.js';
10+
11+
vi.mock('../../../paths.js', () => ({
12+
findAndroidBuildTool: vi.fn(),
13+
getAndroidBuildToolsPath: vi.fn(() => '/mock/sdk/build-tools'),
14+
}));
15+
16+
const { findAndroidBuildTool } = await import('../../../paths.js');
17+
18+
const pluginApi = {
19+
registerCommand: vi.fn(),
20+
} as unknown as PluginApi;
21+
22+
const MOCK_TEMP_DIR = '/tmp/mock_elf_';
23+
24+
const OBJDUMP_ALIGNED = [
25+
' LOAD off 0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**14',
26+
' LOAD off 0x0000000000004000 vaddr 0x0000000000004000 paddr 0x0000000000004000 align 2**14',
27+
].join('\n');
28+
29+
const OBJDUMP_UNALIGNED = [
30+
' LOAD off 0x0000000000000000 vaddr 0x0000000000000000 paddr 0x0000000000000000 align 2**12',
31+
' LOAD off 0x0000000000001000 vaddr 0x0000000000001000 paddr 0x0000000000001000 align 2**12',
32+
].join('\n');
33+
34+
function makeDirent(name: string, isDir: boolean): Dirent {
35+
return {
36+
name,
37+
isDirectory: () => isDir,
38+
isFile: () => !isDir,
39+
isBlockDevice: () => false,
40+
isCharacterDevice: () => false,
41+
isFIFO: () => false,
42+
isSocket: () => false,
43+
isSymbolicLink: () => false,
44+
parentPath: '',
45+
path: '',
46+
};
47+
}
48+
49+
function setupExtractedLibs(structure: Record<string, string[]>) {
50+
vi.spyOn(fs.promises, 'readdir').mockImplementation(
51+
((dirPath: PathLike) => {
52+
const dir = dirPath.toString();
53+
54+
if (dir === MOCK_TEMP_DIR) {
55+
return Promise.resolve([makeDirent('lib', true)]);
56+
}
57+
58+
if (dir === `${MOCK_TEMP_DIR}/lib`) {
59+
const abis = Object.keys(structure).map((key) =>
60+
key.replace('lib/', ''),
61+
);
62+
return Promise.resolve(abis.map((abi) => makeDirent(abi, true)));
63+
}
64+
65+
for (const [abiPath, files] of Object.entries(structure)) {
66+
const abi = abiPath.replace('lib/', '');
67+
if (dir === `${MOCK_TEMP_DIR}/lib/${abi}`) {
68+
return Promise.resolve(files.map((f) => makeDirent(f, false)));
69+
}
70+
}
71+
72+
return Promise.resolve([]);
73+
}) as never,
74+
);
75+
}
76+
77+
function mockSpawnForLibs(
78+
opts:
79+
| { alignment: string; alignmentByPath?: never }
80+
| { alignmentByPath: Record<string, string>; alignment?: never },
81+
) {
82+
(spawn as Mock).mockImplementation((file: string, args: string[]) => {
83+
if (file === 'unzip') {
84+
return Promise.resolve({ output: '' });
85+
}
86+
87+
if (file === 'file') {
88+
return Promise.resolve({
89+
output: `${args[0]}: ELF 64-bit LSB shared object`,
90+
});
91+
}
92+
93+
if (file === 'objdump') {
94+
const filePath = args[1] ?? '';
95+
if (opts.alignmentByPath) {
96+
for (const [key, value] of Object.entries(opts.alignmentByPath)) {
97+
if (filePath.includes(key)) {
98+
return Promise.resolve({ output: value });
99+
}
100+
}
101+
}
102+
return Promise.resolve({
103+
output: opts.alignment ?? OBJDUMP_ALIGNED,
104+
});
105+
}
106+
107+
return Promise.resolve({ output: '' });
108+
});
109+
}
110+
111+
beforeEach(() => {
112+
vi.clearAllMocks();
113+
vi.restoreAllMocks();
114+
vi.mocked(findAndroidBuildTool).mockReturnValue(null);
115+
vi.mocked(fs.existsSync).mockReturnValue(true);
116+
vi.spyOn(fs.promises, 'mkdtemp').mockResolvedValue(MOCK_TEMP_DIR);
117+
vi.spyOn(fs.promises, 'rm').mockResolvedValue();
118+
});
119+
120+
// --- Command registration tests ---
121+
122+
test('registers validate-elf-alignment command metadata', () => {
123+
registerValidateElfAlignmentCommand(pluginApi);
124+
125+
const [command] = vi.mocked(pluginApi.registerCommand).mock.calls[0];
126+
127+
expect(command.name).toBe('validate-elf-alignment');
128+
expect(command.args).toEqual(
129+
expect.arrayContaining([expect.objectContaining({ name: 'binaryPath' })]),
130+
);
131+
});
132+
133+
test('action passes binary path to validateElfAlignment', async () => {
134+
const spy = vi
135+
.spyOn(validateElfAlignmentModule, 'validateElfAlignment')
136+
.mockResolvedValue();
137+
registerValidateElfAlignmentCommand(pluginApi);
138+
const [command] = vi.mocked(pluginApi.registerCommand).mock.calls[0];
139+
140+
await command.action('/tmp/app.apk');
141+
142+
expect(spy).toHaveBeenCalledWith('/tmp/app.apk');
143+
expect(outro).toHaveBeenCalledWith('Success 🎉.');
144+
});
145+
146+
test('action throws when APK path is missing', async () => {
147+
registerValidateElfAlignmentCommand(pluginApi);
148+
const [command] = vi.mocked(pluginApi.registerCommand).mock.calls[0];
149+
150+
await expect(
151+
command.action(undefined),
152+
).rejects.toThrowErrorMatchingInlineSnapshot(
153+
`[RockError: Missing APK path. Provide it as an argument.]`,
154+
);
155+
});
156+
157+
// --- ELF alignment regex tests ---
158+
159+
test.each([
160+
['2**14', true],
161+
['2**15', true],
162+
['2**16', true],
163+
['2**19', true],
164+
['2**20', true],
165+
['2**99', true],
166+
['2**100', true],
167+
['2**12', false],
168+
['2**13', false],
169+
['2**0', false],
170+
['2**1', false],
171+
['2**9', false],
172+
['2**10', false],
173+
])('ELF_ALIGNMENT_REGEX matches %s → %s', (value, expected) => {
174+
expect(ELF_ALIGNMENT_REGEX.test(value)).toBe(expected);
175+
});
176+
177+
// --- validateElfAlignment internal tests ---
178+
179+
test('validateElfAlignment throws when APK not found', async () => {
180+
vi.mocked(fs.existsSync).mockReturnValue(false);
181+
182+
await expect(
183+
validateElfAlignment('/missing/app.apk'),
184+
).rejects.toThrowErrorMatchingInlineSnapshot(
185+
`[RockError: APK not found "/missing/app.apk".]`,
186+
);
187+
});
188+
189+
test('validateElfAlignment skips non-APK files', async () => {
190+
await validateElfAlignment('/path/to/app.aab');
191+
192+
expect(logger.info).toHaveBeenCalledWith(
193+
'Skipping ELF alignment check because output is not an APK.',
194+
);
195+
expect(spawn).not.toHaveBeenCalled();
196+
});
197+
198+
test('validateElfAlignment handles APK with no native libs (unzip exit code 11)', async () => {
199+
(spawn as Mock).mockRejectedValue({ exitCode: 11 });
200+
201+
await validateElfAlignment('/path/to/app.apk');
202+
203+
expect(logger.info).toHaveBeenCalledWith(
204+
'No native shared libraries found in APK. Skipping ELF alignment check.',
205+
);
206+
expect(fs.promises.rm).toHaveBeenCalledWith(MOCK_TEMP_DIR, {
207+
recursive: true,
208+
force: true,
209+
});
210+
});
211+
212+
test('validateElfAlignment passes when all libs are aligned', async () => {
213+
setupExtractedLibs({
214+
'lib/arm64-v8a': ['libfoo.so'],
215+
});
216+
mockSpawnForLibs({ alignment: OBJDUMP_ALIGNED });
217+
218+
await validateElfAlignment('/path/to/app.apk');
219+
220+
expect(logger.info).toHaveBeenCalledWith('ELF alignment check passed.');
221+
});
222+
223+
test('validateElfAlignment fails when arm64-v8a lib is unaligned', async () => {
224+
setupExtractedLibs({
225+
'lib/arm64-v8a': ['libfoo.so'],
226+
});
227+
mockSpawnForLibs({ alignment: OBJDUMP_UNALIGNED });
228+
229+
await expect(
230+
validateElfAlignment('/path/to/app.apk'),
231+
).rejects.toThrowErrorMatchingInlineSnapshot(
232+
`[RockError: ELF alignment check failed.]`,
233+
);
234+
235+
expect(logger.warn).toHaveBeenCalledWith(
236+
expect.stringContaining('must be 16KB aligned'),
237+
);
238+
});
239+
240+
test('validateElfAlignment fails when x86_64 lib is unaligned', async () => {
241+
setupExtractedLibs({
242+
'lib/x86_64': ['libfoo.so'],
243+
});
244+
mockSpawnForLibs({ alignment: OBJDUMP_UNALIGNED });
245+
246+
await expect(
247+
validateElfAlignment('/path/to/app.apk'),
248+
).rejects.toThrow(RockError);
249+
});
250+
251+
test('validateElfAlignment passes when only 32-bit libs are unaligned', async () => {
252+
setupExtractedLibs({
253+
'lib/arm64-v8a': ['libfoo.so'],
254+
'lib/armeabi-v7a': ['libfoo.so'],
255+
});
256+
mockSpawnForLibs({
257+
alignmentByPath: {
258+
arm64: OBJDUMP_ALIGNED,
259+
armeabi: OBJDUMP_UNALIGNED,
260+
},
261+
});
262+
263+
await validateElfAlignment('/path/to/app.apk');
264+
265+
expect(logger.info).toHaveBeenCalledWith(
266+
expect.stringContaining('1 unaligned libs'),
267+
);
268+
expect(logger.info).toHaveBeenCalledWith('ELF alignment check passed.');
269+
});
270+
271+
test('validateElfAlignment logs zipalign not found notice when build tool is missing', async () => {
272+
setupExtractedLibs({ 'lib/arm64-v8a': ['libfoo.so'] });
273+
mockSpawnForLibs({ alignment: OBJDUMP_ALIGNED });
274+
275+
await validateElfAlignment('/path/to/app.apk');
276+
277+
expect(logger.info).toHaveBeenCalledWith(
278+
expect.stringContaining('zipalign'),
279+
);
280+
});
281+
282+
test('validateElfAlignment cleans up temp dir even when error is thrown', async () => {
283+
setupExtractedLibs({ 'lib/arm64-v8a': ['libfoo.so'] });
284+
mockSpawnForLibs({ alignment: OBJDUMP_UNALIGNED });
285+
286+
await expect(
287+
validateElfAlignment('/path/to/app.apk'),
288+
).rejects.toThrow();
289+
290+
expect(fs.promises.rm).toHaveBeenCalledWith(MOCK_TEMP_DIR, {
291+
recursive: true,
292+
force: true,
293+
});
294+
});
295+
296+
test('validateElfAlignment throws when unzip fails with non-11 exit code', async () => {
297+
(spawn as Mock).mockRejectedValue({
298+
exitCode: 1,
299+
stderr: 'corrupt archive',
300+
});
301+
302+
await expect(
303+
validateElfAlignment('/path/to/app.apk'),
304+
).rejects.toThrowErrorMatchingInlineSnapshot(
305+
`[RockError: Failed to extract shared libraries from APK: /path/to/app.apk]`,
306+
);
307+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { PluginApi } from '@rock-js/config';
2+
import { outro, RockError } from '@rock-js/tools';
3+
import { validateElfAlignment } from './validateElfAlignment.js';
4+
5+
const ARGUMENTS = [
6+
{
7+
name: 'binaryPath',
8+
description: 'Path to APK file to validate.',
9+
},
10+
];
11+
12+
export function registerValidateElfAlignmentCommand(api: PluginApi) {
13+
api.registerCommand({
14+
name: 'validate-elf-alignment',
15+
description: 'Validate ELF alignment of shared libraries in an APK.',
16+
args: ARGUMENTS,
17+
action: async (binaryPath: string | undefined) => {
18+
if (!binaryPath) {
19+
throw new RockError(
20+
'Missing APK path. Provide it as an argument.',
21+
);
22+
}
23+
await validateElfAlignment(binaryPath);
24+
outro('Success 🎉.');
25+
},
26+
});
27+
}

0 commit comments

Comments
 (0)