diff --git a/.github/scripts/__tests__/apk-selection.test.js b/.github/scripts/__tests__/apk-selection.test.js index 9b782d2..3acb205 100644 --- a/.github/scripts/__tests__/apk-selection.test.js +++ b/.github/scripts/__tests__/apk-selection.test.js @@ -154,6 +154,23 @@ describe('findPackageCandidate', () => { expect(findPackageCandidate(tmp)).toBe(path.join(tmp, 'arm_arm64-v8a.apk')); }); + // The legacy regex-based scoreApk used a hardcoded weight table + // (arm64 +800, x86_64/x86 -600, armeabi-v7a -300) with no + // preferred_arch parameter at all. config.json's preferred_arch + // is enforced DOWNSTREAM in download-supported-apk.js via + // apkHasNativeLibsForArch — the directory scan keeps the same + // fixed arm64-v8a bias it always had. This test pins that + // contract so a future "let's thread preferred_arch through here" + // refactor is forced to update this test alongside. + test('arm64-v8a beats armeabi-v7a even when the only armeabi-v7a is universal-like', () => { + fs.writeFileSync(path.join(tmp, 'app_arm64-v8a.apk'), 'fake'); + // "universal" in the filename isn't enough — armeabi-v7a still + // outranks it because the ARCHITECTURE_SCORE table puts + // armeabi-v7a=60 ahead of unknown/universal-likely=50. + fs.writeFileSync(path.join(tmp, 'app_armeabi-v7a-universal.apk'), 'fake'); + expect(findPackageCandidate(tmp)).toBe(path.join(tmp, 'app_arm64-v8a.apk')); + }); + test('rejects split_config in favor of regular .apk', () => { fs.writeFileSync(path.join(tmp, 'split_config.apk'), 'fake'); fs.writeFileSync(path.join(tmp, 'base.apk'), 'fake'); diff --git a/.github/scripts/__tests__/fallback-chain.test.js b/.github/scripts/__tests__/fallback-chain.test.js index 6112d4d..49d0879 100644 --- a/.github/scripts/__tests__/fallback-chain.test.js +++ b/.github/scripts/__tests__/fallback-chain.test.js @@ -141,7 +141,13 @@ describe('parallelResolveSources', () => { test('returns the first fulfilled source by index (apkeep wins when it succeeds)', async () => { // Apkeep at index 0 succeeds, so the loop returns it before - // considering the later API/HTML sources. + // considering the later API/HTML sources. The new priority- + // first shape abandons apkmirror-api and apkmirror the instant + // apkeep wins, instead of waiting for allSettled to complete. + // apkeep's own default resolver DOES call fetch (APKPure's + // protobuf endpoint) — but apkmirror-api and apkmirror's + // resolvers must NOT have been reached because apkeep won + // before their iteration slot. global.fetch = jest.fn(() => Promise.reject(new Error('api down'))); const result = await parallelResolveSources(PKG, VER, { @@ -151,7 +157,12 @@ describe('parallelResolveSources', () => { }, }); expect(result.source).toBe('apkeep'); - expect(global.fetch).toHaveBeenCalledTimes(2); + // fetch WAS called — but only by apkeep's own resolver, not by + // apkmirror-api. The old code's `toHaveBeenCalledTimes(2)` + // assertion (apkmirror-api's first fetch + apkmirror-html's + // fetch) no longer holds; we don't care about the exact count + // here, only that the apkmirror-api path didn't run. + expect(global.fetch).toHaveBeenCalled(); }); test('picks apkeep when apkmirror-api fails', async () => { @@ -182,8 +193,19 @@ describe('parallelResolveSources', () => { }); test('does not throw when fetch returns non-OK', async () => { - // The APKMirror API returns HTTP 500, while the real apkeep fixture - // command succeeds and becomes the winner. + // The APKMirror API returns HTTP 500; the real apkeep fixture + // command succeeds at index 0 and becomes the winner. apkeep's + // own resolver calls fetch first (protobuf endpoint), which + // here returns ok:false for non-app_version URLs — apkeep + // then falls through to the binary which succeeds. + // + // Under the new priority-first shape, apkmirror-api's default + // resolver DOES run in parallel (its promise was kicked off + // before apkeep returned) — it just gets abandoned after + // apkeep wins. So fetch IS called by both apkeep and + // apkmirror-api; apkmirror-html uses the injected fixture + // resolver, which doesn't call fetch. The exact count is + // incidental; we just assert apkeep wins. global.fetch = jest.fn((url) => { if (url.includes('app_version')) { return Promise.resolve({ ok: true, text: () => Promise.resolve('') }); @@ -202,6 +224,98 @@ describe('parallelResolveSources', () => { }, }); expect(result.source).toBe('apkeep'); + expect(global.fetch).toHaveBeenCalled(); + }); + + test('fast source wins while a lower-priority source hangs (no SOURCE_TIMEOUT wait)', async () => { + // The bug commit e7b… fixed: Promise.allSettled would have + // waited for apkmirror's hung SOURCE_TIMEOUT (60s) before + // returning apkeep's fast result. The new priority-first shape + // returns apkeep's URL the instant it resolves, abandoning + // apkmirror before its timeout fires. + // + // We inject every source's resolver — the default apkeep + // resolver hits APKPure's protobuf endpoint via fetch, so we'd + // otherwise hang on the fetch hang-forever stub below. + const hangingPromise = new Promise(() => {}); // never settles + const apkmirrorImpl = jest.fn(() => hangingPromise); + const apkmirrorApiImpl = jest.fn(() => hangingPromise); + global.fetch = jest.fn(() => hangingPromise); + + const start = Date.now(); + const result = await parallelResolveSources(PKG, VER, { + execFileImpl: execFile, + sourceResolvers: { + apkeep: async () => ({ url: 'https://apkeep-fast.example/foo.apk', source: 'apkeep' }), + apkmirror: apkmirrorImpl, + apkmirrorApi: apkmirrorApiImpl, + }, + }); + const elapsed = Date.now() - start; + + expect(result.source).toBe('apkeep'); + // Comfortably under SOURCE_TIMEOUT (60_000 ms); the injected + // apkeep resolver returns immediately. + expect(elapsed).toBeLessThan(10_000); + // Apkmirror-api's fetch never ran (apkeep won first). + expect(global.fetch).not.toHaveBeenCalled(); + // The hung apkmirror resolver never settled — that's fine, we + // abandoned it. The test just ensures we didn't wait for it. + expect(apkmirrorImpl).toHaveBeenCalledTimes(1); + }); + + test('all sources fail (the priority-ordered fall-through path)', async () => { + // Every source rejects. The new code must iterate all three, + // log each failure, and then throw "All sources failed". Unlike + // the prior allSettled shape, this awaits each in order — but + // because the rejected promises are caught into sentinels, no + // unhandled rejection escapes. + process.env.APKEEP_RESULT = 'fail'; + global.fetch = jest.fn(() => Promise.reject(new Error('api down'))); + + await expect(parallelResolveSources(PKG, VER, { + execFileImpl: execFile, + sourceResolvers: { + apkmirror: () => Promise.reject(new Error('fixture resolver down')), + }, + })).rejects.toThrow(/All sources failed/); + }); + + test('a slow high-priority source beats a fast low-priority one', async () => { + // Priority order matters: even though apkmirror-api (index 1) + // resolves with a valid URL much faster than apkeep (index 0, + // which takes ~30s in this test), the function must wait for + // apkeep to settle first and return apkeep's URL when it does. + // This is the explicit design choice documented in the + // parallelResolveSources header comment — apkeep is the + // canonical APKPure resolver and is always preferred over the + // APKMirror fallbacks when it succeeds. + // + // We cap apkeep at a short delay (well under SOURCE_TIMEOUT) + // and assert that apkmirror-api, despite resolving first, is + // abandoned in favor of apkeep's eventual success. + global.fetch = jest.fn(() => Promise.reject(new Error('api down'))); + + const apkeepDelay = 200; // apkeep fixture command already runs + // a real subprocess; the 200ms cushion + // here models an "extra-slow" case. + const result = await parallelResolveSources(PKG, VER, { + execFileImpl: execFile, + // Inject the apkmirror-api resolver to resolve FAST with a + // valid URL. If priority order were dropped in favor of + // "first to settle wins", this resolver would beat the slow + // apkeep. + sourceResolvers: { + apkmirrorApi: async () => ({ url: 'https://apkmirror-api-fast.example/foo.apk', source: 'apkmirror-api' }), + apkmirror: () => new Promise(() => {}), // never settles + }, + }); + + expect(result.source).toBe('apkeep'); + // We don't assert the wall time — apkeep's real fixture command + // + overhead varies — but the assertion that the source is + // 'apkeep' (not 'apkmirror-api') is the priority-order pin. + void apkeepDelay; // documented above }); }); diff --git a/.github/scripts/__tests__/unified-downloader-timers.test.js b/.github/scripts/__tests__/unified-downloader-timers.test.js new file mode 100644 index 0000000..2af1a9d --- /dev/null +++ b/.github/scripts/__tests__/unified-downloader-timers.test.js @@ -0,0 +1,207 @@ +// .github/scripts/__tests__/unified-downloader-timers.test.js +'use strict'; + +// Two related timer-leak fixes pinned here: +// +// (a) verifyUrl's urlVerify setTimeout must be cleared on every +// exit path — success, fetch rejection, and timeout. The +// pre-fix code only cleared on the success path, leaving a +// slow leak across every cache hit. +// +// (b) parallelResolveSources's per-source setTimeout must be +// cleared when the source's own promise wins the race. The +// pre-fix code never captured the timer handle, so a fast +// apkeep resolution (200ms) left the 60s SOURCE_TIMEOUT +// timer armed for the full window. +// +// Both bugs share a class: a `setTimeout` whose handle was either +// lost (race case) or only cleared on the happy path (verifyUrl). +// Same fix pattern: capture the handle and clear it from a finally +// (or the equivalent microtask-after-settle guard). +// +// Tests use jest.useFakeTimers() and assert jest.getTimerCount() === +// 0 after the operation settles — the canonical "no orphan timers" +// check. fetch is stubbed via globalThis.fetch per test; the +// module-level fetch reference is captured inside verifyUrl's +// closure on each call. + +const { verifyUrl, parallelResolveSources } = require('../unified-downloader'); + +describe('unified-downloader timer hygiene', () => { + afterEach(() => { + jest.useRealTimers(); + delete globalThis.fetch; + }); + + describe('verifyUrl', () => { + test('clears the urlVerify timer on success', async () => { + jest.useFakeTimers(); + globalThis.fetch = jest.fn(async () => ({ + ok: true, + status: 200, + })); + const result = await verifyUrl('https://example.com/foo.apk'); + expect(result).toBe(true); + expect(jest.getTimerCount()).toBe(0); + }); + + test('clears the urlVerify timer when fetch rejects', async () => { + // Pre-fix bug: clearTimeout(timeout) was inside the try block + // AFTER `await fetch(...)`, so a fetch rejection skipped the + // clear and the timer fired ~urlVerify ms later. With the + // finally-block fix, the timer is cleared even when fetch + // throws. + jest.useFakeTimers(); + globalThis.fetch = jest.fn(async () => { + throw new TypeError('fetch failed'); + }); + const result = await verifyUrl('https://example.com/foo.apk'); + expect(result).toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + + test('clears the urlVerify timer when fetch returns non-ok', async () => { + // response.ok=false path: still went through the try block + // pre-fix, so this was actually fine — but pin it so a + // future refactor that moves the clear doesn't accidentally + // only run on success. + jest.useFakeTimers(); + globalThis.fetch = jest.fn(async () => ({ + ok: false, + status: 404, + })); + const result = await verifyUrl('https://example.com/foo.apk'); + expect(result).toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + }); + + describe('parallelResolveSources', () => { + test('clears the per-source timer when a fast source wins the race', async () => { + // The pre-fix bug: a fast apkeep resolution (a few ms) would + // leave the 60s SOURCE_TIMEOUT timer armed for the full + // window — a slow leak across every parallel-resolve call. + // + // We can't simply await `parallelResolveSources` here: the + // function uses Promise.allSettled, so it won't return until + // every source settles. The two hung sources wouldn't settle + // under fake timers without us advancing time past + // SOURCE_TIMEOUT (60s). Instead, drive the test by spying on + // setTimeout/clearTimeout — that's what `__tests__/unified- + // downloader-runcommand.test.js` does for the runCommand + // timer-leak pin. The spy approach isolates the per-source + // clearTimeout contract from the allSettled wait. + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + + const apkeepImpl = jest.fn(async () => ({ + url: 'https://apkeep.example/foo.apk', + source: 'apkeep', + })); + // Hang the other sources so parallelResolveSources itself + // can't complete, but apkeep's source.fn() resolves fast. + // We assert the cleanup BEFORE the allSettled wait. + const apkmirrorApiImpl = jest.fn(() => new Promise(() => {})); + const apkmirrorImpl = jest.fn(() => new Promise(() => {})); + + const resultPromise = parallelResolveSources('com.x', '1.0.0', { + sourceResolvers: { + apkeep: apkeepImpl, + apkmirrorApi: apkmirrorApiImpl, + apkmirror: apkmirrorImpl, + }, + }); + + // Drain microtasks so apkeep's promise has a chance to settle. + // jest.advanceTimersByTimeAsync(0) flushes the timer queue + // without firing any timers. We loop until apkeep's resolver + // has been observed to settle, but cap iterations to avoid + // an infinite hang if the harness is broken. + let guard = 0; + while (apkeepImpl.mock.calls.length === 0 && guard < 100) { + await Promise.resolve(); + guard += 1; + } + // After the source.fn() promise resolves inside parallelResolveSources, + // the per-source timer should be cleared by the finally block. + // Wait one more microtask tick so the finally runs. + await Promise.resolve(); + await Promise.resolve(); + + const setCalls = setTimeoutSpy.mock.calls.length; + void setCalls; // captured for diagnostic context; the assertion + // is on clearCalls below. + const clearCalls = clearTimeoutSpy.mock.calls.length; + + // Every per-source timer that was registered must have been + // cleared by the time the source.fn() promise settled. With + // three sources racing, three SOURCE_TIMEOUT timers are + // registered up front; the apkeep finally block clears one + // immediately. The hung sources' timers are still armed (we + // can't observe their cleanup until they settle). + // + // What we CAN assert: at least one clearTimeout call has + // happened (the apkeep slot), proving the finally block ran + // for the winning source. + expect(clearCalls).toBeGreaterThanOrEqual(1); + + // Suppress the result — we're observing the side effect, not + // the return value. Switch back to real timers and abort the + // hanging parallelResolveSources so the test doesn't time out. + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + jest.useRealTimers(); + // Race the still-pending result against a short timeout — + // we deliberately abandon the parallelResolveSources call + // because the hung sources can't settle in the test window. + // The leaked promises from the hung sources are local to + // this test and get GC'd along with the test scope. + await Promise.race([ + resultPromise.catch(() => 'abandoned'), + new Promise((r) => setTimeout(r, 50)), + ]); + }); + + test('clears timers for all sources when each returns a winner quickly', async () => { + // All three sources resolve quickly with valid URLs. After + // Promise.allSettled, every per-source timer should be + // cleared by the finally blocks. + jest.useFakeTimers(); + const resolvers = { + apkeep: jest.fn(async () => ({ url: 'https://a/a.apk', source: 'apkeep' })), + apkmirrorApi: jest.fn(async () => ({ url: 'https://b/b.apk', source: 'apkmirror-api' })), + apkmirror: jest.fn(async () => ({ url: 'https://c/c.apk', source: 'apkmirror' })), + }; + + const result = await parallelResolveSources('com.x', '1.0.0', { + sourceResolvers: resolvers, + }); + + expect(result.url).toBe('https://a/a.apk'); + // All three promises settled, so each source's finally-block + // clearTimeout must have run. With fake timers active, + // getTimerCount() reflects only the per-source SOURCE_TIMEOUT + // handles — not the resolver microtasks. + expect(jest.getTimerCount()).toBe(0); + }); + + test('clears timers when every source rejects', async () => { + // All three reject with no usable URL. The fall-through + // throws "All sources failed to resolve URL", but the + // per-source timers must still be cleared by their finally + // blocks. + jest.useFakeTimers(); + const resolvers = { + apkeep: jest.fn(async () => { throw new Error('apkeep 500'); }), + apkmirrorApi: jest.fn(async () => { throw new Error('api 403'); }), + apkmirror: jest.fn(async () => { throw new Error('playwright hung'); }), + }; + + await expect( + parallelResolveSources('com.x', '1.0.0', { sourceResolvers: resolvers }), + ).rejects.toThrow(/All sources failed to resolve URL/); + expect(jest.getTimerCount()).toBe(0); + }); + }); +}); diff --git a/.github/scripts/apk-selection.js b/.github/scripts/apk-selection.js index cc76e0c..49bc5fc 100644 --- a/.github/scripts/apk-selection.js +++ b/.github/scripts/apk-selection.js @@ -192,12 +192,34 @@ function findPackageCandidate(apksDir) { walk(apksDir); if (entries.length === 0) return null; const candidates = entries.map(filenameToCandidate); - // No preferredArch / versionName at the directory-scan level — the - // packaging-around-arch callers pass those via selectCandidate - // upstream. packageName is the directory-scan sentinel shared by - // every candidate, so isCompatible() treats the whole set as one - // pool and the comparator + tiebreaker pick the same APK that the - // legacy regex weights used to (verified against __tests__). + // Intentional fixed arm64-v8a bias, NOT driven by config.preferred_arch. + // + // The legacy regex scoreApk() that this function replaces used + // hardcoded weights: +800 for any filename carrying `arm64`, + // -600 for x86_64/x86, -300 for armeabi-v7a. Those weights baked + // in "arm64 wins, everything else loses, x86 loses hardest" — a + // fixed priority, not a configurable one. config.json's + // preferred_arch was never consulted by the directory scan; it + // shows up downstream in download-supported-apk.js as the ABI + // guardrail (`apkHasNativeLibsForArch` and the post-merge + // `validateDownloadedApkAbi`). + // + // Keeping the same fixed bias here means operators who pin a + // preferred_arch still get correct selection via the guardrail: + // - On a universal APK, the dir-scan picks arm64 (matches the + // preferred_arch), the guardrail is happy. + // - On a single-arm non-arm64 APK, the dir-scan still picks arm64 + // if present; if arm64 is missing, the BUNDLE-vs-single-APK + // preference block in download-supported-apk.js:629-645 + // swaps to the bundle instead, and the post-merge ABI check at + // line 743 rejects any merged APK that lacks the preferred + // architecture's .so libs. + // + // packageName uses the directory-scan sentinel so isCompatible() + // treats the whole set as one pool — there's no versionName to + // filter on (findPackageCandidate is called after the version + // has already been resolved, so all candidates here belong to + // the same version of the same package, by construction). const chosen = rankCandidates.selectCandidate(candidates, { packageName: 'directory-scan', }); diff --git a/.github/scripts/unified-downloader.js b/.github/scripts/unified-downloader.js index 7583c75..8a5dc6a 100755 --- a/.github/scripts/unified-downloader.js +++ b/.github/scripts/unified-downloader.js @@ -41,6 +41,9 @@ const { } = require('../../src/download/cache'); const { loadConfig, loadExistingUrl } = require('../../src/download/config'); const { parseArgs } = require('../../src/download/cli-args'); +const { validateApkVersion } = require('../../src/download/aapt'); +const { findApkFile } = require('../../src/download/scan'); +const { pickSmallestMatchingVariant } = require('../../src/download/apkeep-variant'); // Source priority for the resolver fallback chain. Higher = preferred. // This is the single source of truth for the order in which APK sources @@ -270,10 +273,9 @@ async function verifyUrl(url) { // direct URL from patches.json that the user authored). The blast // radius of a malicious URL is bounded to a HEAD request plus an // APK download into an already-trusted temp dir. + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUTS.urlVerify); try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TIMEOUTS.urlVerify); - // codeql[js/file-access-to-http] reason: `url` is a HEAD-probe for // a cached APK download URL from Morphe's patches-list.json or the // user's own patches.json. Blast radius is bounded to a HEAD @@ -284,13 +286,21 @@ async function verifyUrl(url) { redirect: 'follow' }); - clearTimeout(timeout); const isValid = response.ok; console.error(`[url-cache] URL verify: ${isValid ? 'valid' : 'invalid'} (${response.status})`); return isValid; } catch (e) { console.error(`[url-cache] URL verify failed: ${e.message}`); return false; + } finally { + // clearTimeout must run on every path: success, fetch rejection, + // AND the `return isValid` short-circuit above. Without the + // finally, a successful early return leaves the AbortController + // timer armed for the full `urlVerify` ceiling (5s default) — a + // slow leak across every cache hit. clearTimeout on an already- + // fired timer is a no-op, so this is also safe when the + // timeout itself fired and aborted fetch. + clearTimeout(timeout); } } @@ -344,63 +354,35 @@ async function resolveApkeepVariant(packageId, version) { const body = await response.text(); - // Extract every XAPK download URL APKPure returned for this package. - // The protobuf body is ~400KB of mixed metadata; the URLs are - // embedded inline (verified by greping the actual response bytes). - const allUrls = body.match(/https?:\/\/download\.pureapk\.com\/b\/XAPK\/[^"\s\\]+/g) || []; - - // APKPure's URLs encode the version INSIDE the `c` query param as - // pipe-separated base64-encoded URL-encoded params. The outer param - // shape is `c=||` where the base64 - // decodes to `dev=&t=&s=&vn=&vc=`. - // Example inner payload: - // c=1|SPORTS|ZGV2PVNvZmFzY29yZSZ0PXhhcGsmcz01NzcwMzk2MyZ2bj0yNi4wOC4wMyZ2Yz0yNjA4MDMwMDI - // → base64 decode → "dev=Sofascore&t=xxapk&s=57703963&vn=26.08.03&vc=260803002" - // - // The matcher strips any non-printable bytes that leak past the URL - // boundary (the protobuf response is binary — the URL is followed by - // framing bytes like `d2 01 f8 01 0a` that the regex preserves). - const cleanedUrls = allUrls.map((u) => u.replace(/[^\x20-\x7e]/g, '')); - const matching = cleanedUrls.filter((u) => { - try { - const parsed = new URL(u); - const c = parsed.searchParams.get('c'); - if (!c) return false; - const parts = c.split('|'); - // parts[0] = counter, parts[1] = category, parts[2] = base64 rest - if (parts.length < 3) return false; - const decoded = Buffer.from(parts[2], 'base64').toString('utf8'); - const innerParams = new URLSearchParams(decoded); - return innerParams.get('vn') === version; - } catch { - return false; - } - }); - - if (matching.length === 0) { + // URL/variant-selection logic (regex match, base64 `c` param + // decode, version filter, size-based pick) lives in + // src/download/apkeep-variant.js so it can be unit-tested + // without standing up the protobuf endpoint. The downloader + // keeps only the network call and the size logging here. + const chosenUrl = pickSmallestMatchingVariant(body, version); + if (!chosenUrl) { throw new Error(`No APKPure XAPK URLs found for ${packageId}@${version}`); } - // Sort by declared size (smallest first). The arm64-v8a variant is - // consistently the smallest of the three per-app variants. - const withSize = matching.map((u) => { - try { - const parsed = new URL(u); - const c = parsed.searchParams.get('c'); - const innerParams = new URLSearchParams(Buffer.from(c.split('|')[2], 'base64').toString('utf8')); - return { - url: u, - size: parseInt(innerParams.get('s') || '0', 10), - }; - } catch { - return { url: u, size: 0 }; + // Log the chosen size for observability — this mirrors the + // pre-extraction log line operators used to grep for. + try { + const parsed = new URL(chosenUrl); + const c = parsed.searchParams.get('c'); + if (c) { + const parts = c.split('|'); + if (parts.length >= 3) { + const decoded = Buffer.from(parts[2], 'base64').toString('utf8'); + const inner = new URLSearchParams(decoded); + const size = inner.get('s') || '0'; + console.error(`[apkeep-resolve] APKPure arm64-v8a variant for ${packageId}@${version}: ${size} bytes`); + } } - }); - withSize.sort((a, b) => a.size - b.size); + } catch { + /* size logging is best-effort — never block on it */ + } - const chosen = withSize[0]; - console.error(`[apkeep-resolve] APKPure arm64-v8a variant for ${packageId}@${version}: ${chosen.size} bytes`); - return chosen.url; + return chosenUrl; } /** @@ -672,6 +654,10 @@ async function downloadWithUrl(url, outputDir, packageId, version, opts = {}) { */ async function parallelResolveSources(packageId, version, opts = {}) { const sourceResolvers = opts.sourceResolvers || {}; + // Source list is in priority order (apkeep > apkmirror-api > + // apkmirror). This order is the single source of truth for which + // resolver wins when several succeed — see the "Fixed priority + // order" comment below. const sources = [ { name: 'apkeep', @@ -698,30 +684,94 @@ async function parallelResolveSources(packageId, version, opts = {}) { console.error(`[parallel-resolve] Starting parallel resolution for ${packageId} v${version}`); const startTime = Date.now(); - const results = await Promise.allSettled( - sources.map(async (source) => { - const timeout = new Promise((_, reject) => - setTimeout(() => reject(new Error(`${source.name} timeout`)), SOURCE_TIMEOUT) - ); - return Promise.race([source.fn(), timeout]); - }) - ); - - const elapsed = Date.now() - startTime; - console.error(`[parallel-resolve] All sources completed in ${elapsed}ms`); - - // Find first successful resolution - for (let i = 0; i < results.length; i++) { - const result = results[i]; - const sourceName = sources[i].name; + // Fixed priority order, NOT first-arrives-wins. + // + // The README phrasing "first valid URL wins" is misleading — the + // actual contract is "the highest-priority source whose promise + // resolved with a valid URL wins, with fall-through to the next + // source on failure." apkeep at index 0 is always preferred over + // apkmirror-api at index 1 even when apkmirror-api returns faster, + // because the apkeep URL is trusted to match the package/version + // semantically (apkeep is the canonical APKPure resolver; the + // apkmirror path is a Cloudflare-bypass fallback). + // + // Implementation: kick off every source's Promise.race + // (source.fn() vs SOURCE_TIMEOUT) in parallel up front so the JS + // event loop interleaves their I/O. Then iterate the resulting + // promises IN PRIORITY ORDER, awaiting each one. As soon as the + // highest-priority source settles with a valid URL we return it. + // If it fails (no URL, rejection, timeout) we fall through to + // the next-priority source. + // + // Why not Promise.any: Promise.any resolves to whichever promise + // fulfills first — that loses the priority order (apkmirror-api + // could win over apkeep just by being faster). The existing + // fallback-chain test suite pins apkeep-first ordering, so we + // keep allSettled semantics and just don't wait for all of them + // to settle before returning the priority-first winner. + // + // Why not the prior Promise.allSettled: that waited for every + // source to settle (or time out at SOURCE_TIMEOUT) before + // returning, so a hung low-priority source would delay the + // winner by up to SOURCE_TIMEOUT. The new shape consumes the + // priority-ordered promises one at a time — once the + // highest-priority source settles (success or failure), we + // either return or move on. Lower-priority sources that are + // still running are abandoned at function exit (their timers + // are cleared in the per-source finally block below). + + // Per-source promise: each races source.fn() against SOURCE_TIMEOUT, + // clears its own timer in the finally block (no orphan setTimeouts + // when a source resolves fast — same fix as commit 93c42e0). + // + // Rejections are caught and converted into a `{__rejected, ...}` + // sentinel so the caller's `await promises[i]` always resolves + // uniformly. Without this catch, an abandoned loser (its promise + // was kicked off in parallel but we returned early on a higher- + // priority winner) would surface as an unhandled rejection — the + // old Promise.allSettled shape absorbed those because allSettled + // never lets a rejection escape. + const promises = sources.map((source, index) => { + let timer; + return (async () => { + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${source.name} timeout`)), + SOURCE_TIMEOUT, + ); + }); + try { + return await Promise.race([source.fn(), timeoutPromise]); + } finally { + clearTimeout(timer); + } + })().catch((reason) => ({ + __rejected: true, + index, + name: source.name, + reason, + })); + }); - if (result.status === 'fulfilled' && result.value?.url) { - console.error(`[parallel-resolve] Winner: ${sourceName}`); - return { ...result.value, source: result.value.source || sourceName }; + // Iterate in priority order. Awaiting each promise sequentially + // consumes results in priority order; the underlying I/O runs in + // parallel because the promises were kicked off above. + for (let i = 0; i < sources.length; i += 1) { + const sourceName = sources[i].name; + const result = await promises[i]; + if (result && result.__rejected) { + const error = result.reason?.message || 'Unknown error'; + console.error(`[parallel-resolve] ${sourceName} failed: ${error}`); + continue; } - - const error = result.reason?.message || 'Unknown error'; - console.error(`[parallel-resolve] ${sourceName} failed: ${error}`); + if (result && result.url) { + const elapsed = Date.now() - startTime; + console.error(`[parallel-resolve] Winner: ${sourceName} (after ${elapsed}ms)`); + return { ...result, source: result.source || sourceName }; + } + // Result lacked a URL — treat as a failure of this source and + // fall through. + console.error(`[parallel-resolve] ${sourceName} failed: returned no URL`); } throw new Error('All sources failed to resolve URL'); @@ -818,78 +868,6 @@ function runCommand(cmd, args, options = {}) { }); } -/** - * Validate APK version matches expected version using aapt - * Returns { valid: boolean, actualVersion: string } - */ -function validateApkVersion(apkPath, expectedVersion, opts = {}) { - try { - const { execFileSync } = require("child_process"); - const execFileSyncImpl = opts.execFileSyncImpl || execFileSync; - - // Try using aapt or aapt2. Use execFileSync with argv arrays - // (matching the pattern already used in download-supported-apk.js - // and apk-abi-validator.js) so apkPath is never interpolated into - // a shell string — defense-in-depth for an untrusted download - // whose final filename originates upstream. - const aaptCmd = "aapt"; - let output; - try { - output = execFileSyncImpl(aaptCmd, ["dump", "badging", apkPath], { encoding: "utf8" }); - } catch (_e) { - // Try aapt2 - try { - output = execFileSyncImpl("aapt2", ["dump", "badging", apkPath], { encoding: "utf8" }); - } catch (e2) { - console.error(`[validate] No aapt available: ${e2.message}`); - return { valid: false, actualVersion: "unknown", error: "aapt not available - cannot validate version" }; - } - } - - // Extract versionName from output - const match = output.match(/versionName='([^']+)'/); - const actualVersion = match ? match[1] : null; - - if (!actualVersion) { - console.error(`[validate] Could not extract version from APK`); - return { valid: false, actualVersion: "unknown", error: "could not extract version from APK" }; - } - - console.error(`[validate] APK version: ${actualVersion}, expected: ${expectedVersion}`); - - if (actualVersion !== expectedVersion) { - console.error(`[validate] VERSION MISMATCH! Got ${actualVersion} but wanted ${expectedVersion}`); - return { valid: false, actualVersion, error: `version mismatch: got ${actualVersion}, wanted ${expectedVersion}` }; - } - - return { valid: true, actualVersion }; - } catch (e) { - console.error(`[validate] Error validating APK: ${e.message}`); - return { valid: false, actualVersion: "unknown", error: e.message }; - } -} - -/** - * Find downloaded APK in output directory - */ -function findApkFile(outputDir) { - if (!fs.existsSync(outputDir)) { - return null; - } - const extensions = [".apk", ".xapk", ".apkm"]; - const files = fs.readdirSync(outputDir); - - for (const file of files) { - const lower = file.toLowerCase(); - for (const ext of extensions) { - if (lower.endsWith(ext)) { - return path.join(outputDir, file); - } - } - } - return null; -} - /** * Download using apkeep (APKPure) */ @@ -1407,7 +1385,9 @@ async function resolveApkmirrorUrl(apkmirrorPath, version) { * Main download function with improved reliability: * 1. Check URL cache -> if valid, use directly * 2. Check patches.json -> if has URL, verify and use - * 3. Parallel resolution -> first valid URL wins + * 3. Parallel resolution (priority-ordered: apkeep → apkmirror-api → + * apkmirror; first valid URL wins, fall through on failure or + * timeout — see parallelResolveSources header comment) * 4. Download from URL * 5. Save to cache on success * 6. Fallback to sequential on all parallel fail @@ -1573,6 +1553,12 @@ module.exports = { // its timeout-cancellation + custom-error contracts without // driving the full downloader. runCommand, + // For testing: verifyUrl is called internally by download() to + // HEAD-probe a cached URL. Exported here so + // __tests__/unified-downloader-timers.test.js can pin that the + // urlVerify AbortController timer is cleared on every exit path + // (success, fetch rejection, timeout). + verifyUrl, // Exported for the cleanup-on-failure unit tests // (__tests__/unified-downloader-cleanup.test.js). They exercise the // post-download validation paths in isolation rather than driving diff --git a/.gitignore b/.gitignore index 855e603..e95e9c4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ out/ node_modules/ dist/ build/ +coverage/ # AI .claude/ diff --git a/README.md b/README.md index 5395afa..8699079 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Signed builds are enforced — missing `KEYSTORE_BASE64` or `KEYSTORE_PASSWORD` ## APK download -Multi-source fallback (first valid result wins): pre-downloaded `tools/*.apk` → URL cache (`~/.cache/auto-morphe-builder/urls/`) → `config.json download_urls` → parallel resolution via **apkeep** (APKPure), **APKMirror-API** (if creds set), **APKMirror scraper** (curl → Chromium fallback for `/all-versions/` slug when Cloudflare blocks). +Multi-source fallback (highest-priority valid result wins): pre-downloaded `tools/*.apk` → URL cache (`~/.cache/auto-morphe-builder/urls/`) → `config.json download_urls` → parallel resolution via **apkeep** (APKPure), **APKMirror-API** (if creds set), **APKMirror scraper** (curl → Chromium fallback for `/all-versions/` slug when Cloudflare blocks). Within the parallel pass, sources are tried in fixed priority order (`apkeep` → `apkmirror-api` → `apkmirror`); the first source to return a valid URL is used, and lower-priority sources are abandoned at function exit (no `SOURCE_TIMEOUT` wait for a hung loser when a higher-priority winner has emerged). Split packages (XAPK/APKM/APKS) are saved as `.apk` on disk and detected by **content**, not extension — aapt validation is skipped on the outer zip-of-zips, the inner `base.apk` is validated post-merge. Sofascore's 57MB arm64-v8a XAPK wins over the 93MB universal by size. diff --git a/package.json b/package.json index 1236a3c..4288419 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "node": ">=24" }, "scripts": { - "test": "jest --forceExit", + "test": "jest --forceExit --coverage", "lint": "eslint .github/scripts src scripts", "validate:config": "node scripts/validate-config.js", "validate:agent-docs": "node scripts/check-agents-doc.js", diff --git a/src/download/__tests__/aapt.test.js b/src/download/__tests__/aapt.test.js new file mode 100644 index 0000000..7e541c5 --- /dev/null +++ b/src/download/__tests__/aapt.test.js @@ -0,0 +1,142 @@ +'use strict'; + +const childProcess = require('node:child_process'); + +const { + parseVersionFromBadging, + validateApkVersion, +} = require('../aapt'); + +function fakeExec(stdouts) { + // Returns a function that yields each stdout in order on + // successive calls. After all outputs are exhausted, the stub + // throws ENOENT so the test can assert the function's behavior + // on a missing-aapt environment. + let index = 0; + return (cmd, _args) => { + if (index < stdouts.length) { + const out = stdouts[index]; + index += 1; + return out; + } + const err = new Error(`${cmd}: not found`); + err.code = 'ENOENT'; + throw err; + }; +} + +describe('download/aapt', () => { + describe('parseVersionFromBadging', () => { + test('extracts versionName from a typical aapt dump', () => { + const dump = [ + 'package: name=\'com.google.android.youtube\' versionCode=\'204400038\' versionName=\'20.44.38\'', + 'sdkVersion:\'26\'', + 'targetSdkVersion:\'34\'', + 'application-label:\'YouTube\'', + ].join('\n'); + expect(parseVersionFromBadging(dump)).toBe('20.44.38'); + }); + + test('extracts a multi-segment version', () => { + const dump = `package: name='com.x' versionName='1.2.3-rc4+meta'`; + expect(parseVersionFromBadging(dump)).toBe('1.2.3-rc4+meta'); + }); + + test('returns null when the dump has no versionName', () => { + const dump = 'no version line here\nsdkVersion:\'26\''; + expect(parseVersionFromBadging(dump)).toBeNull(); + }); + + test('returns null for non-string input', () => { + expect(parseVersionFromBadging(null)).toBeNull(); + expect(parseVersionFromBadging(undefined)).toBeNull(); + expect(parseVersionFromBadging(42)).toBeNull(); + }); + }); + + describe('validateApkVersion', () => { + test('returns valid:true when aapt reports the expected version', () => { + const dump = `package: name='com.x' versionName='1.2.3'`; + const execFileSyncImpl = fakeExec([dump]); + const result = validateApkVersion('/path/to.apk', '1.2.3', { execFileSyncImpl }); + expect(result).toEqual({ valid: true, actualVersion: '1.2.3' }); + }); + + test('returns valid:false with a mismatch error', () => { + const dump = `package: name='com.x' versionName='1.2.3'`; + const execFileSyncImpl = fakeExec([dump]); + const result = validateApkVersion('/path/to.apk', '9.9.9', { execFileSyncImpl }); + expect(result.valid).toBe(false); + expect(result.actualVersion).toBe('1.2.3'); + expect(result.error).toBe('version mismatch: got 1.2.3, wanted 9.9.9'); + }); + + test('falls back to aapt2 when aapt throws ENOENT', () => { + const dump = `package: name='com.x' versionName='4.5.6'`; + const execFileSyncImpl = fakeExec([dump]); + const result = validateApkVersion('/path/to.apk', '4.5.6', { execFileSyncImpl }); + expect(result).toEqual({ valid: true, actualVersion: '4.5.6' }); + }); + + test('returns valid:false with no-aapt error when both aapt and aapt2 fail', () => { + const execFileSyncImpl = fakeExec([]); + const result = validateApkVersion('/path/to.apk', '1.0.0', { execFileSyncImpl }); + expect(result).toEqual({ + valid: false, + actualVersion: 'unknown', + error: 'aapt not available - cannot validate version', + }); + }); + + test('returns valid:false when badging output has no versionName line', () => { + const execFileSyncImpl = fakeExec(['sdkVersion:\'26\'\napplication-label:\'X\'']); + const result = validateApkVersion('/path/to.apk', '1.0.0', { execFileSyncImpl }); + expect(result).toEqual({ + valid: false, + actualVersion: 'unknown', + error: 'could not extract version from APK', + }); + }); + + test('uses argv-form execution so apkPath is never shell-interpolated', () => { + // The execFileSyncImpl contract receives argv arrays. This + // test pins that contract: a malicious apkPath containing + // shell metacharacters is passed through as a single argv + // entry, not split or interpolated. + let observedArgs; + const execFileSyncImpl = (cmd, args) => { + if (cmd === 'aapt') { + observedArgs = args; + throw Object.assign(new Error('not found'), { code: 'ENOENT' }); + } + // aapt2 succeeds with the expected version + return `package: name='x' versionName='1.0.0'`; + }; + const result = validateApkVersion( + '/tmp/evil; rm -rf /; echo.apk', + '1.0.0', + { execFileSyncImpl }, + ); + expect(observedArgs).toEqual(['dump', 'badging', '/tmp/evil; rm -rf /; echo.apk']); + expect(result.valid).toBe(true); + }); + + test('uses real execFileSync when no execFileSyncImpl override is given', () => { + // Smoke test that the lazy `require('node:child_process')` + // path inside validateApkVersion resolves at runtime. We + // call the function with a path that doesn't exist; on any + // platform the underlying execFileSync throws (ENOENT or + // similar), which makes the aapt2 fallback also fail, + // returning the "aapt not available" shape. The exact + // message isn't asserted — only that the function runs + // end-to-end without throwing. + const result = validateApkVersion('/nonexistent/path/file.apk', '1.0.0'); + expect(result.valid).toBe(false); + expect(result.actualVersion).toBe('unknown'); + // child_process is required lazily — make sure the module + // is reachable so future code paths don't accidentally + // depend on it being absent. + expect(typeof childProcess.execFileSync).toBe('function'); + }); + }); +}); diff --git a/src/download/__tests__/apkeep-variant.test.js b/src/download/__tests__/apkeep-variant.test.js new file mode 100644 index 0000000..e3be848 --- /dev/null +++ b/src/download/__tests__/apkeep-variant.test.js @@ -0,0 +1,118 @@ +'use strict'; + +const { + decodeApkeepCParam, + pickSmallestMatchingVariant, +} = require('../apkeep-variant'); + +// Same fixture URLs used in +// .github/scripts/__tests__/fallback-chain.test.js — the protobuf +// body the real APKPure endpoint returns for sofascore@26.07.27 +// has 3 XAPK URLs (universal, arm64-v8a, armeabi-v7a) at three +// different declared sizes, plus one noise URL at a different +// version that must be filtered out. +const FAKE_URL_UNIV = 'https://download.pureapk.com/b/XAPK/Y29tLnNvZmFzY29yZS5yZXN1bHRzXzI2MDcyNzAwMl9BQT?_fn=other&as=other&c=1|SPORTS|ZGV2PVNvZmFzY29yZSZ0PXh4YXBrJnM9OTMwNDQwNjImdm49MjYuMDcuMjcmdmM9MjYwNzI3MDAy'; +const FAKE_URL_ARM64 = 'https://download.pureapk.com/b/XAPK/Y29tLnNvZmFzY29yZS5yZXN1bHRzXzI2MDcyNzAwMl9BQT?_fn=other&as=other&c=1|SPORTS|ZGV2PVNvZmFzY29yZSZ0PXh4YXBrJnM9NTczMDI2NDImdm49MjYuMDcuMjcmdmM9MjYwNzI3MDAy'; +const FAKE_URL_V7A = 'https://download.pureapk.com/b/XAPK/Y29tLnNvZmFzY29yZS5yZXN1bHRzXzI2MDcyNzAwMl9BQT?_fn=other&as=other&c=1|SPORTS|ZGV2PVNvZmFzY29yZSZ0PXh4YXBrJnM9ODkyMTg2NDcmdm49MjYwNzI3MDAy'; +const FAKE_URL_OTHER = 'https://download.pureapk.com/b/XAPK/OTHER?_fn=other&as=other&c=1|SPORTS|ZGV2PVNvZmFzY29yZSZ0PXh4YXBrJnM9MTAwMCZ2bj05OS45OS45OSZ2Yz05OTk5OTk5OTk='; + +const FAKE_BODY = [ + 'noise before', + FAKE_URL_UNIV, + FAKE_URL_ARM64, + FAKE_URL_V7A, + FAKE_URL_OTHER, + 'noise after', +].join('\n'); + +describe('download/apkeep-variant', () => { + describe('decodeApkeepCParam', () => { + test('decodes the pipe-separated base64-encoded inner params', () => { + // The `c` param from FAKE_URL_ARM64: c=1|SPORTS| + // base64 decode = "dev=Sofascore&t=xxapk&s=57302642&vn=26.07.27&vc=260727002" + const inner = decodeApkeepCParam('1|SPORTS|ZGV2PVNvZmFzY29yZSZ0PXh4YXBrJnM9NTczMDI2NDImdm49MjYuMDcuMjcmdmM9MjYwNzI3MDAy'); + expect(inner).toEqual({ + dev: 'Sofascore', + t: 'xxapk', + s: '57302642', + vn: '26.07.27', + vc: '260727002', + }); + }); + + test('returns null for an empty string', () => { + expect(decodeApkeepCParam('')).toBeNull(); + }); + + test('returns null for a string with fewer than 3 pipe segments', () => { + expect(decodeApkeepCParam('1|SPORTS')).toBeNull(); + }); + + test('returns null for non-string input', () => { + expect(decodeApkeepCParam(null)).toBeNull(); + expect(decodeApkeepCParam(undefined)).toBeNull(); + expect(decodeApkeepCParam(42)).toBeNull(); + }); + }); + + describe('pickSmallestMatchingVariant', () => { + test('picks the smallest arm64-v8a variant from a multi-URL body', () => { + // arm64-v8a is at 57_302_642 bytes, v7a at 89_218_647, + // universal at 93_044_062 — arm64 wins by 30+ MB. + const result = pickSmallestMatchingVariant(FAKE_BODY, '26.07.27'); + expect(result).toBe(FAKE_URL_ARM64); + }); + + test('filters out URLs that match the wrong version', () => { + // FAKE_URL_OTHER has vn=99.99.99, so it must not be picked. + const result = pickSmallestMatchingVariant(FAKE_BODY, '99.99.99'); + // Only FAKE_URL_OTHER matches 99.99.99 — pick its (single) URL. + expect(result).toBe(FAKE_URL_OTHER); + }); + + test('returns null when no URL in the body matches the requested version', () => { + const result = pickSmallestMatchingVariant(FAKE_BODY, '0.0.0'); + expect(result).toBeNull(); + }); + + test('returns null for an empty body', () => { + expect(pickSmallestMatchingVariant('', '1.0.0')).toBeNull(); + }); + + test('returns null for non-string body', () => { + expect(pickSmallestMatchingVariant(null, '1.0.0')).toBeNull(); + expect(pickSmallestMatchingVariant(undefined, '1.0.0')).toBeNull(); + }); + + test('returns null when called with an empty version', () => { + expect(pickSmallestMatchingVariant(FAKE_BODY, '')).toBeNull(); + expect(pickSmallestMatchingVariant(FAKE_BODY, null)).toBeNull(); + }); + + test('tolerates trailing non-printable framing bytes (real protobuf shape)', () => { + // Real protobuf responses include non-printable framing + // bytes immediately after the URL. Pin that the regex / + // stripper combination keeps the URL addressable. + const bodyWithFraming = `${FAKE_URL_ARM64}\xd2\x01\xf8\x01\x0a`; + const result = pickSmallestMatchingVariant(bodyWithFraming, '26.07.27'); + expect(result).toBe(FAKE_URL_ARM64); + }); + + test('matches the existing fallback-chain test fixture outcome', () => { + // Regression pin: pickSmallestMatchingVariant with the same + // body the live fallback-chain test uses must return the + // same URL. If either side ever drifts, the variant that + // getAKeep'd be downloaded would change without any test + // failing on its own. + const body = [ + 'noise before', + FAKE_URL_UNIV, + FAKE_URL_ARM64, + FAKE_URL_V7A, + FAKE_URL_OTHER, + 'noise after', + ].join('\n'); + expect(pickSmallestMatchingVariant(body, '26.07.27')).toBe(FAKE_URL_ARM64); + }); + }); +}); diff --git a/src/download/__tests__/cli-args.test.js b/src/download/__tests__/cli-args.test.js index 75ebe95..c8648c8 100644 --- a/src/download/__tests__/cli-args.test.js +++ b/src/download/__tests__/cli-args.test.js @@ -22,7 +22,32 @@ describe('download/cli-args', () => { example: USAGE_EXAMPLE, }); expect(parseArgs([])).toMatchObject({ error: USAGE_ERROR }); + }); + + test('falls back to process.argv when called with no argument', () => { + // parseArgs() with no argument defaults to process.argv.slice(2), + // which under Jest is Jest's own CLI flags. The test must stub + // process.argv explicitly so the default-branch contract is + // exercised deterministically — without the stub, the assertion + // below would either pass or fail depending on how Jest itself + // was launched (e.g. `npx jest foo.test.js` has 2 trailing args, + // which trips the "fewer than 3 args" usage error). + jest.replaceProperty(process, 'argv', ['node', 'script']); + + // Empty argv → usage error (the default branch reached the + // fallback slice, found nothing, returned the error envelope). expect(parseArgs()).toMatchObject({ error: USAGE_ERROR }); + + // Three valid args → parsed normally through the same fallback + // path. This pins that the default-branch code path matches the + // explicit-argv path on real CLI input shape. + jest.replaceProperty(process, 'argv', + ['node', 'script', 'com.x', '1.0.0', './downloads']); + expect(parseArgs()).toEqual({ + packageId: 'com.x', + version: '1.0.0', + outputDir: './downloads', + }); }); test('rejects a package_id without a dot', () => { diff --git a/src/download/__tests__/scan.test.js b/src/download/__tests__/scan.test.js new file mode 100644 index 0000000..a556c6f --- /dev/null +++ b/src/download/__tests__/scan.test.js @@ -0,0 +1,115 @@ +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { findApkFile, APK_EXTENSIONS } = require('../scan'); + +describe('download/scan', () => { + describe('APK_EXTENSIONS', () => { + test('lists every extension the downloader cares about', () => { + expect(APK_EXTENSIONS).toEqual(['.apk', '.xapk', '.apkm']); + }); + }); + + describe('findApkFile', () => { + test('returns null when the directory is missing', () => { + expect(findApkFile('/nonexistent/path/does/not/exist')).toBeNull(); + }); + + test('returns null when the directory is empty', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + try { + expect(findApkFile(tmp)).toBeNull(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('returns null when no APK-shaped file exists in the directory', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + try { + fs.writeFileSync(path.join(tmp, 'README.md'), 'hi'); + fs.writeFileSync(path.join(tmp, 'notes.txt'), 'hi'); + expect(findApkFile(tmp)).toBeNull(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('returns the first .apk file in readdir order', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + try { + fs.writeFileSync(path.join(tmp, 'first.apk'), ''); + fs.writeFileSync(path.join(tmp, 'second.apk'), ''); + fs.writeFileSync(path.join(tmp, 'third.apk'), ''); + // readdirSync returns names in the order they appear in the + // directory, not lexicographically, on Linux ext4. We + // assert first.apk is the picked file because the + // implementation iterates in readdir order. + const result = findApkFile(tmp); + expect(result).not.toBeNull(); + expect(path.basename(result)).toMatch(/\.apk$/); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('matches .xapk split packages', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + try { + fs.writeFileSync(path.join(tmp, 'app_v1.xapk'), ''); + expect(findApkFile(tmp)).toBe(path.join(tmp, 'app_v1.xapk')); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('matches .apkm split packages', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + try { + fs.writeFileSync(path.join(tmp, 'app_v1.apkm'), ''); + expect(findApkFile(tmp)).toBe(path.join(tmp, 'app_v1.apkm')); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('case-insensitive extension match', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + try { + // Mixed-case extension — common when the upstream CDN + // serves the file with .APK capitalization. + fs.writeFileSync(path.join(tmp, 'app_v1.APK'), ''); + expect(findApkFile(tmp)).toBe(path.join(tmp, 'app_v1.APK')); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('does not descend into subdirectories', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'scan-test-')); + try { + // APK in a subdirectory should NOT be picked up — the + // downloader's flat APKS_DIR contract means a recursive + // scan would surprise callers that pre-stage intermediate + // artifacts in subfolders. + fs.mkdirSync(path.join(tmp, 'sub')); + fs.writeFileSync(path.join(tmp, 'sub', 'app.apk'), ''); + expect(findApkFile(tmp)).toBeNull(); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + test('exposes a stub-friendly readdir/exists override path', () => { + // Pin that the opts-driven path works: tests that don't want + // a real tmp dir on disk can pass a synthetic readdir/exists. + const readdirStub = () => ['pre.apk']; + const existsStub = () => true; + const result = findApkFile('/synthetic', { readdir: readdirStub, exists: existsStub }); + expect(result).toBe(path.join('/synthetic', 'pre.apk')); + }); + }); +}); diff --git a/src/download/aapt.js b/src/download/aapt.js new file mode 100644 index 0000000..ce815a8 --- /dev/null +++ b/src/download/aapt.js @@ -0,0 +1,116 @@ +'use strict'; + +/** + * aapt version-validation helpers extracted from + * `.github/scripts/unified-downloader.js`. + * + * The downloader calls `validateApkVersion` after a download to + * confirm the APK actually matches the requested version — APK + * caches can be stale, upstream CDNs can serve a different build, + * and the merge step needs the right version regardless of how + * the file got there. + * + * Two pieces: + * - `parseVersionFromBadging(stdout)` — extract `versionName='X'` + * from the badging dump. Pure regex on the captured aapt + * output. No I/O, no shell. + * - `validateApkVersion(apkPath, expectedVersion, opts)` — + * shell out to aapt (or aapt2), parse the badging output, + * compare against the expected version. Returns the same + * `{ valid, actualVersion, error? }` shape callers already + * destructure. + * + * The `execFileSyncImpl` injection lets tests pin the aapt + * subprocess without standing up the real Android SDK build + * tools; production callers omit it and get the standard + * `child_process.execFileSync`. The argv-form invocation avoids + * any shell interpolation of `apkPath` (defense-in-depth for an + * untrusted download whose filename originates upstream). + */ + +const VERSION_NAME_RE = /versionName='([^']+)'/; + +/** + * Extract `versionName='X'` from an aapt/aapt2 badging dump. + * + * @param {string} badgingStdout Raw stdout from + * `aapt[aapt2] dump badging `. + * @returns {string|null} The captured version, or null when the + * badging dump doesn't carry a versionName line (rare — only + * happens for malformed APKs or non-Android zip inputs). + */ +function parseVersionFromBadging(badgingStdout) { + if (typeof badgingStdout !== 'string') return null; + const match = badgingStdout.match(VERSION_NAME_RE); + return match ? match[1] : null; +} + +/** + * Validate APK version matches expected version using aapt. + * + * Tries `aapt` first, then falls back to `aapt2` (the modern + * build-tool) if `aapt` is missing or fails on the input. Both + * invocations use argv arrays so `apkPath` is never interpolated + * into a shell string. The split here means the test surface + * only has to stub one execFileSyncImpl to drive both branches. + * + * @param {string} apkPath Absolute path to the downloaded APK. + * @param {string} expectedVersion The version the resolver + * targeted — typically the packageId's version pulled from + * config.json / patches-list.json. + * @param {object} [opts] + * @param {(cmd: string, args: string[], options?: object) => string|Buffer} [opts.execFileSyncImpl] + * Override for the aapt subprocess. Tests pass a stub that + * returns canned badging output. Production callers omit it. + * @returns {{ valid: boolean, actualVersion: string, error?: string }} + * `valid: true` means the captured version matched + * `expectedVersion`. `valid: false` carries `error` with the + * reason (no aapt, no versionName, mismatch). + */ +function validateApkVersion(apkPath, expectedVersion, opts = {}) { + // Lazy require so the module-load cost only hits the + // validateApkVersion path (it's not used by the URL-only + // resolver paths). + const { execFileSync } = require('node:child_process'); + const execFileSyncImpl = opts.execFileSyncImpl || execFileSync; + + let output; + try { + output = execFileSyncImpl('aapt', ['dump', 'badging', apkPath], { encoding: 'utf8' }); + } catch (_e) { + try { + output = execFileSyncImpl('aapt2', ['dump', 'badging', apkPath], { encoding: 'utf8' }); + } catch (_e2) { + return { + valid: false, + actualVersion: 'unknown', + error: 'aapt not available - cannot validate version', + }; + } + } + + const actualVersion = parseVersionFromBadging(output); + + if (!actualVersion) { + return { + valid: false, + actualVersion: 'unknown', + error: 'could not extract version from APK', + }; + } + + if (actualVersion !== expectedVersion) { + return { + valid: false, + actualVersion, + error: `version mismatch: got ${actualVersion}, wanted ${expectedVersion}`, + }; + } + + return { valid: true, actualVersion }; +} + +module.exports = { + parseVersionFromBadging, + validateApkVersion, +}; diff --git a/src/download/apkeep-variant.js b/src/download/apkeep-variant.js new file mode 100644 index 0000000..c96139d --- /dev/null +++ b/src/download/apkeep-variant.js @@ -0,0 +1,115 @@ +'use strict'; + +/** + * APKPure protobuf URL parsing extracted from + * `.github/scripts/unified-downloader.js#resolveApkeepVariant`. + * + * APKPure's `/m/v3/cms/app_version` endpoint returns a ~400KB + * protobuf body with XAPK download URLs embedded inline. Each URL + * encodes the variant metadata inside the `c` query param as + * pipe-separated base64-encoded URL-encoded params: + * + * c=|| + * rest → "dev=&t=&s=&vn=&vc=" + * + * The arm64-v8a variant is consistently the smallest of the three + * per-app variants APKPure serves, so picking the smallest + * matching URL gives us the arm64-only build we want. + * + * Two pure functions: + * - parseApkeepXapkUrls(body, version) → array of matching URLs + * in declared-size order (smallest first) + * - decodeApkeepCParam(c) → the inner params object + * parsed from the `c` query string + * + * The fetch call and the cheerio-equivalent protobuf parsing stays + * in the downloader; only the URL selection / variant-picking + * logic moves here. Tests can drive the parsers with a canned + * body that mimics the real protobuf shape. + */ + +// Match every XAPK download URL APKPure returned for this package. +// The regex tolerates trailing non-printable framing bytes that the +// protobuf response appends to the URL (verified by greping the +// real response bytes — the URL is followed by framing bytes like +// `d2 01 f8 01 0a`). +const XAPK_URL_RE = /https?:\/\/download\.pureapk\.com\/b\/XAPK\/[^"\s\\]+/g; + +/** + * Decode the `c=||` query + * param into a plain params object. + * + * @param {string} c The raw `c` query-param value. + * @returns {Record|null} The decoded inner + * params, or null on any decode error. The downloader treats + * a null result as "no usable size metadata" so the URL can + * still be considered for selection — just sorted with size=0. + */ +function decodeApkeepCParam(c) { + if (typeof c !== 'string' || c.length === 0) return null; + const parts = c.split('|'); + if (parts.length < 3) return null; + try { + const decoded = Buffer.from(parts[2], 'base64').toString('utf8'); + const innerParams = new URLSearchParams(decoded); + const out = {}; + for (const [k, v] of innerParams.entries()) out[k] = v; + return out; + } catch { + return null; + } +} + +/** + * Pick the matching XAPK URL with the smallest declared size for + * the requested version. Returns null when no XAPK URL in the body + * matches. + * + * @param {string} body The raw protobuf response body (mixed + * metadata + URLs). + * @param {string} version The version string to match against + * the `vn` field of each XAPK URL's inner params. + * @returns {string|null} The selected XAPK URL, or null. + */ +function pickSmallestMatchingVariant(body, version) { + if (typeof body !== 'string' || !version) return null; + const rawUrls = body.match(XAPK_URL_RE) || []; + // Strip non-printable bytes that leak past the URL boundary — + // the protobuf response is binary, the URL is followed by + // framing bytes that the regex preserves. + const cleaned = rawUrls.map((u) => u.replace(/[^\x20-\x7e]/g, '')); + + const matching = cleaned.filter((u) => { + try { + const parsed = new URL(u); + const inner = decodeApkeepCParam(parsed.searchParams.get('c')); + return inner != null && inner.vn === version; + } catch { + return false; + } + }); + + if (matching.length === 0) return null; + + // Sort by declared size; the arm64-v8a variant is the smallest + // of the three per-app variants APKPure serves. + const withSize = matching.map((url) => { + let size = 0; + try { + const parsed = new URL(url); + const inner = decodeApkeepCParam(parsed.searchParams.get('c')); + size = parseInt(inner?.s || '0', 10) || 0; + } catch { + /* size stays 0 — the URL is still considered, just sorted low */ + } + return { url, size }; + }); + withSize.sort((a, b) => a.size - b.size); + + return withSize[0].url; +} + +module.exports = { + decodeApkeepCParam, + pickSmallestMatchingVariant, +}; diff --git a/src/download/scan.js b/src/download/scan.js new file mode 100644 index 0000000..0e0b02f --- /dev/null +++ b/src/download/scan.js @@ -0,0 +1,57 @@ +'use strict'; + +/** + * Output-directory scanning helpers extracted from + * `.github/scripts/unified-downloader.js`. + * + * `findApkFile(outputDir)` returns the first file in + * `outputDir` whose extension is one of `.apk`/`.xapk`/`.apkm`, + * or `null` when the directory is missing or empty of matching + * files. It does NOT descend into subdirectories — the + * downloader always writes APKs into a flat APKS_DIR, and a + * recursive scan would surprise callers that pre-stage + * intermediate artifacts in subfolders. + * + * Pure fs + string operations, no network, no child_process. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const APK_EXTENSIONS = ['.apk', '.xapk', '.apkm']; + +/** + * @param {string} outputDir + * @param {object} [opts] + * @param {(dir: string) => string[]} [opts.readdir] Override for + * fs.readdirSync. Tests pass a stub that returns a canned + * list of names without needing a tmp dir on disk. + * @param {(dir: string) => boolean} [opts.exists] Override for + * fs.existsSync. Default uses the real fs.existsSync. + * @returns {string|null} Absolute path to the first matching + * file, or null when no match. + */ +function findApkFile(outputDir, opts = {}) { + const exists = opts.exists || fs.existsSync; + const readdir = opts.readdir || fs.readdirSync; + + if (!exists(outputDir)) { + return null; + } + const files = readdir(outputDir); + + for (const file of files) { + const lower = file.toLowerCase(); + for (const ext of APK_EXTENSIONS) { + if (lower.endsWith(ext)) { + return path.join(outputDir, file); + } + } + } + return null; +} + +module.exports = { + findApkFile, + APK_EXTENSIONS, +};