|
| 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 | +}); |
0 commit comments