Skip to content

Commit e5e43a2

Browse files
sunnylqmclaude
andcommitted
fix: package parsing, aab handling and diff stream robustness
- anchor and escape native bundle lookup patterns so .map/.backup entries can no longer shadow the real bundle's bundleHash - only fall back to npx node-bundletool on spawn ENOENT; surface all other bundletool failures as-is - extract the universal APK for aab uploads into a private mkdtemp dir - propagate write/read stream errors when producing diff zips and extracting universal.apk - in the stream diff path, adopt the HBC-transformed patch only when it is smaller than the raw baseline (same rule as the in-memory path) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4c8755c commit e5e43a2

6 files changed

Lines changed: 133 additions & 33 deletions

File tree

src/diff.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ function createOutputZip(output: string) {
113113
const zipfile = new YazlZipFile();
114114
const writePromise = new Promise<void>((resolve, reject) => {
115115
zipfile.outputStream.on('error', reject);
116-
zipfile.outputStream.pipe(fs.createWriteStream(output)).on('close', () => {
116+
const writeStream = fs.createWriteStream(output);
117+
// without this, a full disk / read-only target left the promise pending
118+
writeStream.on('error', reject);
119+
zipfile.outputStream.pipe(writeStream).on('close', () => {
117120
resolve(void 0);
118121
});
119122
});
@@ -248,18 +251,13 @@ async function buildStreamBundlePatch(
248251
originBuffer && newBuffer
249252
? tryTransformPair(originBuffer, newBuffer)
250253
: null;
251-
const oldFile = pair
252-
? path.join(tempRoot, 'old-transformed.bin')
253-
: rawOldFile;
254-
const newFile = pair
255-
? path.join(tempRoot, 'new-transformed.bin')
256-
: rawNewFile;
257-
const patchFile = path.join(tempRoot, 'patch.bin');
254+
const transformedOldFile = path.join(tempRoot, 'old-transformed.bin');
255+
const transformedNewFile = path.join(tempRoot, 'new-transformed.bin');
258256

259257
if (pair) {
260258
await Promise.all([
261-
fs.writeFile(oldFile, pair.tOld),
262-
fs.writeFile(newFile, pair.tNew),
259+
fs.writeFile(transformedOldFile, pair.tOld),
260+
fs.writeFile(transformedNewFile, pair.tNew),
263261
]);
264262
}
265263
reportDiffPhase(options, {
@@ -268,9 +266,29 @@ async function buildStreamBundlePatch(
268266
inputBytes,
269267
});
270268

269+
// 与内存路径同一约束:变换候选连同元数据开销必须比 baseline 更小才采用,
270+
// 因此变换命中时两个候选都要生成再比较。
271271
phaseStartedAt = performance.now();
272-
await diffStreamFn(oldFile, newFile, patchFile);
273-
const patchBytes = (await fs.stat(patchFile)).size;
272+
const rawPatchFile = path.join(tempRoot, 'patch-raw.bin');
273+
await diffStreamFn(rawOldFile, rawNewFile, rawPatchFile);
274+
let patchFile = rawPatchFile;
275+
let patchBytes = (await fs.stat(rawPatchFile)).size;
276+
let chosenPair: typeof pair = null;
277+
if (pair) {
278+
const transformedPatchFile = path.join(tempRoot, 'patch-transformed.bin');
279+
await diffStreamFn(
280+
transformedOldFile,
281+
transformedNewFile,
282+
transformedPatchFile,
283+
);
284+
const transformedPatchBytes = (await fs.stat(transformedPatchFile)).size;
285+
const metaOverhead = Buffer.byteLength(JSON.stringify(pair.meta));
286+
if (transformedPatchBytes + metaOverhead < patchBytes) {
287+
patchFile = transformedPatchFile;
288+
patchBytes = transformedPatchBytes;
289+
chosenPair = pair;
290+
}
291+
}
274292
reportDiffPhase(options, {
275293
phase: 'diff',
276294
durationMs: performance.now() - phaseStartedAt,
@@ -280,13 +298,21 @@ async function buildStreamBundlePatch(
280298

281299
// 变换路径必须验证 T⁻¹ 和元数据;未声明自校验的自定义 diff 也保留
282300
// round-trip。node-hdiffpatch native 普通路径已经在返回前完整验证。
283-
if (pair || !options.streamOutputVerified) {
301+
if (chosenPair || !options.streamOutputVerified) {
284302
phaseStartedAt = performance.now();
285303
const restoredFile = path.join(tempRoot, 'restored.bin');
286-
await patchStreamFn(oldFile, patchFile, restoredFile);
287-
if (pair) {
304+
await patchStreamFn(
305+
chosenPair ? transformedOldFile : rawOldFile,
306+
patchFile,
307+
restoredFile,
308+
);
309+
if (chosenPair) {
288310
const restoredRaw = await fs.readFile(restoredFile);
289-
const restored = transformHbcWithLayout(restoredRaw, pair.layout, true);
311+
const restored = transformHbcWithLayout(
312+
restoredRaw,
313+
chosenPair.layout,
314+
true,
315+
);
290316
if (
291317
!restored ||
292318
!newBuffer ||
@@ -315,7 +341,7 @@ async function buildStreamBundlePatch(
315341

316342
return {
317343
patch: { kind: 'file', path: patchFile },
318-
...(pair ? { hbcTransform: pair.meta } : {}),
344+
...(chosenPair ? { hbcTransform: chosenPair.meta } : {}),
319345
};
320346
}
321347

src/package.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -270,10 +270,12 @@ export const packageCommands = {
270270
}) => {
271271
const source = ensureFileByExt(args[0], '.aab', 'usageUploadAab');
272272

273-
const output = path.join(
274-
os.tmpdir(),
275-
`${path.basename(source, path.extname(source))}-${Date.now()}.apk`,
273+
// private temp dir: unpredictable path, safe against symlink squatting in
274+
// the shared tmpdir and against concurrent uploads of same-named AABs
275+
const tempRoot = await fs.mkdtemp(
276+
path.join(os.tmpdir(), 'rnu-aab-upload-'),
276277
);
278+
const output = path.join(tempRoot, 'universal.apk');
277279

278280
const includeAllSplits = parseBooleanOption(options.includeAllSplits);
279281
const splits = parseCsvOption(options.splits);
@@ -289,9 +291,7 @@ export const packageCommands = {
289291
options,
290292
});
291293
} finally {
292-
if (await fs.pathExists(output)) {
293-
await fs.remove(output);
294-
}
294+
await fs.remove(tempRoot);
295295
}
296296
},
297297
uploadApp: async ({

src/utils/app-info-parser/aab.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,14 @@ export class AabParser extends Zip {
8888
'--overwrite',
8989
...modulesArgs,
9090
]);
91-
} catch (_e) {
91+
} catch (e) {
92+
// Only fall back when bundletool itself is missing from PATH (spawn
93+
// ENOENT). Any other failure (corrupt AAB, Java issues, bad args,
94+
// non-zero exit) must surface as-is instead of being masked by an
95+
// online npx install.
96+
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
97+
throw e;
98+
}
9299
// Fallback to npx node-bundletool if bundletool is not in PATH
93100
// We use -y to avoid interactive prompt for installation
94101
if (await needsNpxDownload()) {
@@ -140,6 +147,7 @@ export class AabParser extends Zip {
140147
zipfile.close();
141148
resolve();
142149
});
150+
readStream.on('error', reject);
143151
writeStream.on('error', reject);
144152
});
145153
} else {

src/utils/index.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,11 @@ async function sha256(data: Buffer | Blob): Promise<string> {
153153
return createHash('sha256').update(buffer).digest('hex');
154154
}
155155

156-
const ApkBundleFileName = /assets\/index.android.bundle/;
157-
const ApkUpdateJsonName = /res\/raw\/update.json/;
156+
// Anchored exact paths: unanchored/unescaped patterns also matched entries
157+
// like index.android.bundle.map or .backup, and a later match would silently
158+
// overwrite the real bundle, registering a wrong bundleHash.
159+
export const ApkBundleFileName = /^assets\/index\.android\.bundle$/;
160+
const ApkUpdateJsonName = /^res\/raw\/update\.json$/;
158161

159162
export async function getApkInfo(fn: string) {
160163
const appInfoParser = new AppInfoParser(fn);
@@ -208,9 +211,9 @@ export async function getAppInfo(fn: string) {
208211
// single scan (and single nested .hap extraction) for all three entries
209212
const [bundleFile, updateJsonFile, metaJsonFile] =
210213
await appInfoParser.parser.getEntriesFromHarmonyApp([
211-
/rawfile\/bundle.harmony.js/,
212-
/rawfile\/update.json/,
213-
/rawfile\/meta.json/,
214+
/^resources\/rawfile\/bundle\.harmony\.js$/,
215+
/^resources\/rawfile\/update\.json$/,
216+
/^resources\/rawfile\/meta\.json$/,
214217
]);
215218
if (!bundleFile) {
216219
throw new Error(
@@ -244,12 +247,14 @@ export async function getAppInfo(fn: string) {
244247
};
245248
}
246249

247-
const IpaBundleFileName = /payload\/.+?\.app\/main.jsbundle/;
248-
const IpaUpdateJsonName = /payload\/.+?\.app\/assets\/update.json/;
249-
const IpaBuildTimeName = /payload\/.+?\.app\/pushy_build_time.txt/;
250+
// lowercase because the zip reader also matches against the lowercased entry
251+
// name (real IPAs use "Payload/")
252+
export const IpaBundleFileName = /^payload\/[^/]+\.app\/main\.jsbundle$/;
253+
const IpaUpdateJsonName = /^payload\/[^/]+\.app\/assets\/update\.json$/;
254+
const IpaBuildTimeName = /^payload\/[^/]+\.app\/pushy_build_time\.txt$/;
250255
// Not in root bundle when use `use_frameworks`
251256
const IpaBuildTimeFrameworkName =
252-
/payload\/.+?\.app\/frameworks\/react_native_update.framework\/pushy_build_time.txt/;
257+
/^payload\/[^/]+\.app\/frameworks\/react_native_update\.framework\/pushy_build_time\.txt$/;
253258

254259
export async function getIpaInfo(fn: string) {
255260
const appInfoParser = new AppInfoParser(fn);

tests/app-info-zip.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'fs';
33
import os from 'os';
44
import path from 'path';
55
import { ZipFile as YazlZipFile } from 'yazl';
6+
import { ApkBundleFileName, IpaBundleFileName } from '../src/utils';
67
import { IpaParser } from '../src/utils/app-info-parser/ipa';
78
import { Zip } from '../src/utils/app-info-parser/zip';
89

@@ -63,6 +64,34 @@ describe('app-info-parser Zip', () => {
6364
expect((buffers['res/icon.png'] as Buffer).toString()).toBe('icon');
6465
});
6566

67+
test('bundle patterns only match the exact bundle entry, not .map/.backup siblings', async () => {
68+
const apkPath = path.join(tempRoot, 'app.apk');
69+
await writeZip(apkPath, {
70+
'assets/index.android.bundle': 'REAL_BUNDLE',
71+
// later entries used to overwrite the real match via the unanchored regex
72+
'assets/index.android.bundle.map': 'SOURCE_MAP',
73+
'assets/index.android.bundle.backup': 'BACKUP',
74+
'foo/assets/index.android.bundle.tmp': 'TMP',
75+
});
76+
77+
const apkEntries = await new Zip(apkPath).getEntries([ApkBundleFileName]);
78+
expect((apkEntries[String(ApkBundleFileName)] as Buffer).toString()).toBe(
79+
'REAL_BUNDLE',
80+
);
81+
82+
const ipaPath = path.join(tempRoot, 'app-bundle.ipa');
83+
await writeZip(ipaPath, {
84+
'Payload/Test.app/main.jsbundle': 'REAL_IPA_BUNDLE',
85+
'Payload/Test.app/main.jsbundle.map': 'IPA_SOURCE_MAP',
86+
'Payload/Test.app/mainXjsbundle': 'NOT_A_BUNDLE',
87+
});
88+
89+
const ipaEntries = await new Zip(ipaPath).getEntries([IpaBundleFileName]);
90+
expect((ipaEntries[String(IpaBundleFileName)] as Buffer).toString()).toBe(
91+
'REAL_IPA_BUNDLE',
92+
);
93+
});
94+
6695
test('parses ipa plist with current plist package exports', async () => {
6796
const ipaPath = path.join(tempRoot, 'app.ipa');
6897
await writeZip(ipaPath, {

tests/diff-stream.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,38 @@ describe('hdiff with bundleStreamThreshold (large-bundle stream path)', () => {
310310
},
311311
);
312312

313+
itIfStream(
314+
'stream path drops the hbc transform when its patch is not smaller than baseline',
315+
async () => {
316+
// 该 diff 把补丁写成完整的 new 内容:raw/transformed 两个候选大小
317+
// 几乎一致,加上元数据开销后变换候选必然不占优,必须回退 baseline
318+
const copyDiff = (_o: string, n: string, out: string) => {
319+
fs.writeFileSync(out, fs.readFileSync(n));
320+
return out;
321+
};
322+
const copyPatch = (_o: string, d: string, out: string) => {
323+
fs.writeFileSync(out, fs.readFileSync(d));
324+
return out;
325+
};
326+
const origin = fixture('v96-a.hbc');
327+
const next = fixture('v96-b.hbc');
328+
const files = await runHdiff(
329+
{ origin, next },
330+
{
331+
hbcTransform: true,
332+
bundleStreamThreshold: 1,
333+
customDiff: (loadHdiff() as HdiffFull).diff,
334+
customDiffSingleStream: copyDiff,
335+
customPatchSingleStream: copyPatch,
336+
},
337+
);
338+
const manifest = JSON.parse(files['__diff.json'].toString('utf8'));
339+
expect(manifest.hbcTransform).toBeUndefined();
340+
// 采用的是 raw 基线候选:补丁内容就是未变换的 new bundle
341+
expect(Buffer.compare(files['index.bundlejs.patch'], next)).toBe(0);
342+
},
343+
);
344+
313345
itIfStream(
314346
'bundles below the threshold keep the in-memory single-format path',
315347
async () => {

0 commit comments

Comments
 (0)