Skip to content

Commit ff0354e

Browse files
committed
fix: address all PR review findings from bots
- HIGH: Fix multi-step patch chain corruption by alternating between two intermediate files (A/B) instead of reading and writing the same file. Prevents mmap invalidation for 3+ step chains. - MEDIUM: Use versioned nightly tag (nightly-<version>) instead of rolling :nightly tag for threshold calculation in resolveNightlyDelta. Ensures the 60% threshold reflects the target version's binary size. - LOW: Add missing 'await' on applyPatchChain return in resolveNightlyDelta to preserve stack traces on rejection. - LOW: Remove redundant chmodSync from applyPatchChain — the caller (downloadBinaryToTemp) already sets 0o755 for both delta and full download paths. - CodeQL: Replace includes('api.github.com') with startsWith('https://api.github.com/') in test mocks to prevent incomplete URL substring sanitization.
1 parent d215dd4 commit ff0354e

File tree

3 files changed

+48
-38
lines changed

3 files changed

+48
-38
lines changed

src/lib/delta-upgrade.ts

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
* - Any error occurs during patch download or application
1818
*/
1919

20-
import { chmodSync } from "node:fs";
20+
import { unlinkSync } from "node:fs";
21+
2122
import { applyPatch } from "./bspatch.js";
2223
import { CLI_VERSION } from "./constants.js";
2324
import {
@@ -621,9 +622,12 @@ export async function resolveNightlyDelta(
621622
): Promise<string | null> {
622623
const token = await getAnonymousToken();
623624

624-
// Get the .gz layer size from the nightly manifest for threshold
625+
// Get the .gz layer size from the target version's manifest for threshold.
626+
// Use the versioned tag (nightly-<version>) so the threshold reflects the
627+
// actual binary being upgraded to, not the latest rolling nightly.
625628
const binaryName = getPlatformBinaryName();
626-
const nightlyManifest = await fetchManifest(token, "nightly");
629+
const targetTag = `nightly-${targetVersion}`;
630+
const nightlyManifest = await fetchManifest(token, targetTag);
627631
const gzLayer = nightlyManifest.layers.find((l) => {
628632
const title = l.annotations?.["org.opencontainers.image.title"];
629633
return title === `${binaryName}.gz`;
@@ -642,15 +646,31 @@ export async function resolveNightlyDelta(
642646
return null;
643647
}
644648

645-
return applyPatchChain(chain, oldBinaryPath, destPath);
649+
return await applyPatchChain(chain, oldBinaryPath, destPath);
650+
}
651+
652+
/** Remove intermediate patching files, ignoring errors. */
653+
function cleanupIntermediates(destPath: string): void {
654+
for (const suffix of [".patching.a", ".patching.b"]) {
655+
try {
656+
unlinkSync(`${destPath}${suffix}`);
657+
} catch {
658+
// Ignore cleanup errors
659+
}
660+
}
646661
}
647662

648663
/**
649664
* Apply a resolved patch chain sequentially and verify the result.
650665
*
651666
* For single-patch chains, applies directly from old binary to dest.
652-
* For multi-patch chains, uses an intermediate temp file: each step
653-
* produces a new file that becomes the input for the next step.
667+
* For multi-patch chains, alternates between two intermediate files
668+
* so that read (mmap) and write never target the same path — writing
669+
* to the mmap source would truncate it and corrupt the output.
670+
*
671+
* Does **not** set executable permissions — the caller
672+
* (`downloadBinaryToTemp`) handles that uniformly for both delta
673+
* and full-download paths.
654674
*
655675
* @param chain - Resolved patch chain with patches and expected hash
656676
* @param oldBinaryPath - Path to the original binary
@@ -666,33 +686,30 @@ export async function applyPatchChain(
666686
let currentOldPath = oldBinaryPath;
667687
let sha256 = "";
668688

669-
// For multi-step chains, intermediate results go to destPath with a step suffix
670-
const intermediatePath = `${destPath}.patching`;
689+
// Alternate between two intermediate paths to avoid reading and writing
690+
// the same file (mmap'd read + writer truncation = corruption).
691+
const intermediateA = `${destPath}.patching.a`;
692+
const intermediateB = `${destPath}.patching.b`;
671693

672694
for (let i = 0; i < chain.patches.length; i++) {
673695
const patch = chain.patches[i];
674696
if (!patch) {
675697
throw new Error(`Missing patch at index ${i}`);
676698
}
677699
const isLast = i === chain.patches.length - 1;
678-
const outputPath = isLast ? destPath : intermediatePath;
700+
const intermediate = i % 2 === 0 ? intermediateA : intermediateB;
701+
const outputPath = isLast ? destPath : intermediate;
679702

680703
sha256 = await applyPatch(currentOldPath, patch.data, outputPath);
681704

682-
// For multi-step: the output of this step becomes the input for the next
683705
if (!isLast) {
684-
currentOldPath = intermediatePath;
706+
currentOldPath = outputPath;
685707
}
686708
}
687709

688-
// Clean up intermediate file if it exists
710+
// Clean up intermediate files
689711
if (chain.patches.length > 1) {
690-
try {
691-
const { unlinkSync: unlink } = await import("node:fs");
692-
unlink(intermediatePath);
693-
} catch {
694-
// Ignore cleanup errors
695-
}
712+
cleanupIntermediates(destPath);
696713
}
697714

698715
// Verify the final SHA-256 matches
@@ -702,10 +719,5 @@ export async function applyPatchChain(
702719
);
703720
}
704721

705-
// Set executable permission (Unix only)
706-
if (process.platform !== "win32") {
707-
chmodSync(destPath, 0o755);
708-
}
709-
710722
return sha256;
711723
}

test/isolated/delta-upgrade.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ describe("resolveStableDelta", () => {
133133

134134
mockFetch(async (url) => {
135135
const urlStr = String(url);
136-
if (urlStr.includes("api.github.com")) {
136+
if (urlStr.startsWith("https://api.github.com/")) {
137137
return new Response(JSON.stringify(releases), { status: 200 });
138138
}
139139
if (urlStr === patchUrl) {
@@ -198,7 +198,7 @@ describe("resolveNightlyDelta", () => {
198198
status: 200,
199199
});
200200
}
201-
if (urlStr.includes("/manifests/nightly")) {
201+
if (urlStr.includes("/manifests/nightly-")) {
202202
return new Response(
203203
JSON.stringify({
204204
schemaVersion: 2,
@@ -250,7 +250,7 @@ describe("resolveNightlyDelta", () => {
250250
status: 200,
251251
});
252252
}
253-
if (urlStr.includes("/manifests/nightly")) {
253+
if (urlStr.includes("/manifests/nightly-")) {
254254
return new Response(
255255
JSON.stringify({
256256
schemaVersion: 2,
@@ -372,7 +372,7 @@ describe("attemptDeltaUpgrade", () => {
372372
// Return garbage patch data
373373
mockFetch(async (url) => {
374374
const urlStr = String(url);
375-
if (urlStr.includes("api.github.com")) {
375+
if (urlStr.startsWith("https://api.github.com/")) {
376376
return new Response(JSON.stringify(releases), { status: 200 });
377377
}
378378
if (urlStr === patchUrl) {

test/lib/delta-upgrade.test.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,7 @@ describe("resolveStableChain", () => {
915915
): void {
916916
mockFetch(async (url) => {
917917
const urlStr = String(url);
918-
if (urlStr.includes("api.github.com")) {
918+
if (urlStr.startsWith("https://api.github.com/")) {
919919
return new Response(JSON.stringify(releases), { status: 200 });
920920
}
921921
const patchData = patches.get(urlStr);
@@ -1371,7 +1371,8 @@ describe("applyPatchChain", () => {
13711371
// the cleanup logic works by checking intermediate files don't leak.
13721372
const oldPath = join(fixturesDir, "small-old.bin");
13731373
const destPath = tempFile("multi-chain-out.bin");
1374-
const intermediatePath = `${destPath}.patching`;
1374+
const intermediateA = `${destPath}.patching.a`;
1375+
const intermediateB = `${destPath}.patching.b`;
13751376
const patchData = await Bun.file(
13761377
join(fixturesDir, "small.trdiff10")
13771378
).bytes();
@@ -1405,19 +1406,17 @@ describe("applyPatchChain", () => {
14051406
if (existsSync(destPath)) {
14061407
unlinkSync(destPath);
14071408
}
1408-
if (existsSync(intermediatePath)) {
1409-
unlinkSync(intermediatePath);
1409+
for (const p of [intermediateA, intermediateB]) {
1410+
if (existsSync(p)) {
1411+
unlinkSync(p);
1412+
}
14101413
}
14111414
}
14121415
});
14131416

1414-
test("sets executable permission on output (Unix)", async () => {
1415-
if (process.platform === "win32") {
1416-
return; // Skip on Windows
1417-
}
1418-
1417+
test("creates output file that is readable", async () => {
14191418
const oldPath = join(fixturesDir, "small-old.bin");
1420-
const destPath = tempFile("perms-out.bin");
1419+
const destPath = tempFile("output-readable.bin");
14211420
const patchData = await Bun.file(
14221421
join(fixturesDir, "small.trdiff10")
14231422
).bytes();
@@ -1443,7 +1442,6 @@ describe("applyPatchChain", () => {
14431442
await applyPatchChain(chain, oldPath, destPath);
14441443

14451444
const stat = Bun.file(destPath);
1446-
// File should exist and be readable
14471445
expect(await stat.exists()).toBe(true);
14481446
} finally {
14491447
if (existsSync(destPath)) {

0 commit comments

Comments
 (0)