diff --git a/package.json b/package.json index 08cc1f496b..86c837595d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/react": "^19.2.17", "@types/semver": "^7.7.1", "@vitest/coverage-v8": "^4.1.9", + "binpatch": "^0.3.0", "chalk": "^5.6.2", "cli-highlight": "^2.1.11", "consola": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eaad7dbcd4..01d3f3dfd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) + binpatch: + specifier: ^0.3.0 + version: 0.3.0 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -1091,6 +1094,10 @@ packages: bare-url@2.4.5: resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + binpatch@0.3.0: + resolution: {integrity: sha512-CYYEXJTUNB6LDgi5OUBScfb4Uxh9BPadQKELm1tP7Jah1WjZZHr/M+4u8kuLxkLXToWruSSQPgmc3y0ukIg3Kg==} + engines: {node: '>=22.5'} + binpunch@1.0.0: resolution: {integrity: sha512-ghxdoerLN3WN64kteDJuL4d9dy7gbvcqoADNRWBk6aQ5FrYH1EmPmREAdcdIdTNAA3uW3V38Env5OqH2lj+i+g==} engines: {node: '>=18'} @@ -3582,6 +3589,8 @@ snapshots: dependencies: bare-path: 3.0.1 + binpatch@0.3.0: {} + binpunch@1.0.0: {} body-parser@2.3.0: diff --git a/src/lib/bspatch.ts b/src/lib/bspatch.ts deleted file mode 100644 index 878a9f45fd..0000000000 --- a/src/lib/bspatch.ts +++ /dev/null @@ -1,854 +0,0 @@ -/** - * Streaming TRDIFF10 Binary Patch Application - * - * Implements the bspatch algorithm for applying binary delta patches in the - * TRDIFF10 format (produced by zig-bsdiff with `--use-zstd`). Designed for - * minimal memory usage during CLI self-upgrades: - * - * - Old binary: copy to temp file, then read on demand via positional `read()` - * (`pread`), so the base never sits fully in the JS heap — only the windows - * actually referenced are pulled in, served from the OS page cache - * - Diff/extra blocks: streamed via zstd `Transform` from `node:zlib` - * - Output: written incrementally to disk via `createWriteStream()` - * - Integrity: SHA-256 computed inline via `node:crypto` - * - * Multi-patch chains keep every intermediate result in memory and only persist - * (and hash) the final binary, avoiding the redundant disk write, temp-copy, and - * SHA-256 pass that a file-by-file chain would incur per hop. The running binary - * is the only input copied to a temp file. See {@link applyPatchChainInMemory}. - * - * The base ("old") bytes are accessed through an {@link OldReader} so the same - * transform serves both an fd-backed on-disk binary (first hop / single patch) - * and an in-memory intermediate buffer (subsequent hops). - * - * TRDIFF10 format (from zig-bsdiff): - * ``` - * [0..8] magic: "TRDIFF10" - * [8..16] controlLen: i64 LE (compressed size of control block) - * [16..24] diffLen: i64 LE (compressed size of diff block) - * [24..32] newSize: i64 LE (expected output size) - * [32..] zstd(control) | zstd(diff) | zstd(extra) - * ``` - */ - -import { createHash } from "node:crypto"; -import { constants, copyFileSync, createWriteStream } from "node:fs"; -import { type FileHandle, open, readFile, unlink } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Readable } from "node:stream"; -import { createZstdDecompress, zstdDecompressSync } from "node:zlib"; - -/** TRDIFF10 header magic bytes */ -const TRDIFF10_MAGIC = "TRDIFF10"; - -/** Header size in bytes (magic + 3 × i64) */ -const HEADER_SIZE = 32; - -/** - * Upper bound on the declared output size (`newSize`) a patch may claim. - * - * The header's `newSize` is attacker-controlled and used to preallocate the - * output buffer BEFORE the patch content is verified, so an unbounded value - * would let a malicious or corrupt patch force an out-of-memory allocation. - * 2 GiB is far above any realistic binary (sentry-cli binaries are ~30 MB); - * legitimate patched outputs never approach it. - */ -export const MAX_OUTPUT_SIZE = 2_147_483_648; // 2 GiB - -/** Parsed TRDIFF10 header fields */ -export type PatchHeader = { - /** Compressed size of the control block (bytes) */ - controlLen: number; - /** Compressed size of the diff block (bytes) */ - diffLen: number; - /** Expected output file size (bytes) */ - newSize: number; -}; - -/** - * Read a signed 64-bit little-endian integer using the zig-bsdiff encoding. - * - * The sign is stored in bit 7 of byte 7 (the MSB of the last byte). - * The magnitude is in the lower 63 bits, read as unsigned LE. - * This differs from standard two's complement — it uses sign-magnitude. - * - * Safe for values up to 2^53 (Number.MAX_SAFE_INTEGER), which covers - * any realistic file size. - * - * @param buf - Buffer to read from - * @param offset - Byte offset to start reading - * @returns Signed integer value - */ -export function offtin(buf: Uint8Array, offset: number): number { - const view = new DataView(buf.buffer, buf.byteOffset + offset, 8); - const lo = view.getUint32(0, true); - const hi = view.getUint32(4, true); - - // Magnitude from lower 63 bits (mask out sign bit in high word). - // getUint32 already returns unsigned, so hi is in [0, 2^32). - const magnitude = (hi % 0x80_00_00_00) * 0x1_00_00_00_00 + lo; - - // Sign in bit 7 of byte 7 (bit 31 of high word). - // Guard magnitude === 0 to avoid returning -0. - if (magnitude !== 0 && hi >= 0x80_00_00_00) { - return -magnitude; - } - return magnitude; -} - -/** - * Parse and validate a TRDIFF10 patch header. - * - * @param patch - Raw patch file data (at least 32 bytes) - * @returns Parsed header with controlLen, diffLen, and newSize - * @throws {Error} When magic is invalid or header values are negative - */ -export function parsePatchHeader(patch: Uint8Array): PatchHeader { - if (patch.byteLength < HEADER_SIZE) { - throw new Error( - `Patch too small: ${patch.byteLength} bytes (need at least ${HEADER_SIZE})` - ); - } - - // Validate magic - const magic = new TextDecoder().decode(patch.subarray(0, 8)); - if (magic !== TRDIFF10_MAGIC) { - throw new Error(`Invalid patch format: expected TRDIFF10, got "${magic}"`); - } - - const controlLen = offtin(patch, 8); - const diffLen = offtin(patch, 16); - const newSize = offtin(patch, 24); - - if (controlLen < 0 || diffLen < 0 || newSize < 0) { - throw new Error("Corrupt patch: negative length in header"); - } - - // Bound the attacker-controlled newSize before it is used to preallocate - // the output buffer (applyReaderToMemory) — see MAX_OUTPUT_SIZE. - if (newSize > MAX_OUTPUT_SIZE) { - throw new Error( - `Corrupt patch: declared output size ${newSize} exceeds maximum ${MAX_OUTPUT_SIZE} bytes` - ); - } - - const totalCompressed = HEADER_SIZE + controlLen + diffLen; - if (totalCompressed > patch.byteLength) { - throw new Error( - `Corrupt patch: header lengths (${totalCompressed}) exceed file size (${patch.byteLength})` - ); - } - - return { controlLen, diffLen, newSize }; -} - -/** - * Buffered reader over a `ReadableStream` that serves exact byte counts. - * - * Wraps a decompression stream output reader to provide `read(n)` semantics: - * pulls chunks from the underlying stream as needed, buffers leftover bytes, - * and returns exactly `n` bytes per call. - */ -class BufferedStreamReader { - private readonly chunks: Uint8Array[] = []; - private buffered = 0; - private done = false; - private readonly reader: ReadableStreamDefaultReader; - - constructor(reader: ReadableStreamDefaultReader) { - this.reader = reader; - } - - /** - * Read exactly `n` bytes from the stream. - * - * @param n - Number of bytes to read - * @returns Uint8Array of exactly `n` bytes - * @throws {Error} When stream ends before `n` bytes are available - */ - async read(n: number): Promise { - // Pull from stream until we have enough buffered - while (this.buffered < n && !this.done) { - const result = await this.reader.read(); - if (result.done) { - this.done = true; - break; - } - this.chunks.push(result.value); - this.buffered += result.value.byteLength; - } - - if (this.buffered < n) { - throw new Error( - `Unexpected end of stream: needed ${n} bytes, have ${this.buffered}` - ); - } - - // Assemble exactly n bytes from buffered chunks - const output = new Uint8Array(n); - let written = 0; - - while (written < n) { - const front = this.chunks[0]; - if (!front) { - break; - } - const needed = n - written; - - if (front.byteLength <= needed) { - // Consume entire chunk - output.set(front, written); - written += front.byteLength; - this.buffered -= front.byteLength; - this.chunks.shift(); - } else { - // Consume partial chunk, keep remainder - output.set(front.subarray(0, needed), written); - this.chunks[0] = front.subarray(needed); - this.buffered -= needed; - written = n; - } - } - - return output; - } - - /** Release the underlying stream reader, cancelling any pending reads. */ - async cancel(): Promise { - try { - await this.reader.cancel(); - } catch { - // Stream may already be closed or errored — safe to ignore - } - try { - this.reader.releaseLock(); - } catch { - // Lock may already be released - } - } -} - -/** - * Create a streaming zstd decompressor from a compressed buffer. - * - * Pipes the compressed data through `node:zlib`'s zstd decompressor and - * returns a BufferedStreamReader for on-demand byte consumption. - * - * @param compressed - Zstd-compressed data - * @returns BufferedStreamReader for incremental decompression - */ -function createZstdStreamReader(compressed: Uint8Array): BufferedStreamReader { - // Convert the node:zlib Transform stream into a Web ReadableStream - // so BufferedStreamReader can consume it with the same interface. - const nodeStream = Readable.from(Buffer.from(compressed)).pipe( - createZstdDecompress() - ); - - const webStream = new ReadableStream({ - start(controller) { - nodeStream.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)); - }); - nodeStream.on("end", () => { - controller.close(); - }); - nodeStream.on("error", (err) => { - controller.error(err); - }); - }, - cancel() { - // Destroy the underlying Node.js stream so buffered data events - // don't fire controller.enqueue() on the now-closed controller. - nodeStream.destroy(); - }, - }); - - return new BufferedStreamReader( - webStream.getReader() as ReadableStreamDefaultReader - ); -} - -/** - * Random-access view of the base ("old") binary during patching. - * - * Abstracts over an fd-backed on-disk file (read on demand via `pread`) and an - * in-memory buffer (a multi-patch intermediate), so {@link transformPatch} can - * source old bytes the same way regardless of where they live. - */ -type OldReader = { - /** - * Read exactly `len` bytes starting at `pos`. - * - * Positions outside `[0, size)` are zero-filled, matching the original - * `oldFile[oldpos + i] ?? 0` semantics (bsdiff seeks can reference offsets - * past the end, and a negative/oversized seek must read as zeros rather than - * fail). The returned buffer is always exactly `len` bytes. - */ - read: (pos: number, len: number) => Promise; - /** Release any held resources (fd, temp copy). Safe to call more than once. */ - close: () => Promise; -}; - -/** - * {@link OldReader} backed by an in-memory buffer. - * - * Used for multi-patch intermediates, whose bytes already live in the JS heap - * and must stay there for the next hop's random access. - */ -class MemoryOldReader implements OldReader { - private readonly data: Uint8Array; - - constructor(data: Uint8Array) { - this.data = data; - } - - read(pos: number, len: number): Promise { - const out = new Uint8Array(len); // zero-filled - const start = Math.max(pos, 0); - const end = Math.min(pos + len, this.data.length); - if (end > start) { - out.set(this.data.subarray(start, end), start - pos); - } - return Promise.resolve(out); - } - - close(): Promise { - // Bytes live in the JS heap — nothing to release. - return Promise.resolve(); - } -} - -/** - * Size of {@link FileOldReader}'s read-ahead cache block (1 MiB). - * - * bsdiff references the base mostly forward in many small windows; caching a - * sliding block of this size collapses thousands of per-window positional reads - * into roughly `fileSize / BLOCK_SIZE` reads, at a bounded ~1 MiB memory cost. - */ -const OLD_READER_BLOCK_SIZE = 1024 * 1024; - -/** - * {@link OldReader} backed by an open file descriptor, read on demand via - * positional reads (`pread`) through a single-block read-ahead cache. - * - * Keeps the base binary out of the JS heap — only the referenced windows are - * pulled in, served from the OS page cache populated by the reflink copy. The - * cache coalesces the many small windowed reads bsdiff performs (mostly forward - * with occasional jumps) into a handful of block reads, avoiding a per-window - * syscall storm while staying bounded at {@link OLD_READER_BLOCK_SIZE}. - */ -class FileOldReader implements OldReader { - private closed = false; - private readonly handle: FileHandle; - private readonly size: number; - private readonly tempPath: string; - - /** Read-ahead block buffer (allocated once, reused across refills). */ - private readonly block: Buffer = Buffer.alloc(OLD_READER_BLOCK_SIZE); - /** File offset the cached block starts at, or -1 when the cache is empty. */ - private blockStart = -1; - /** Number of valid bytes currently held in the block. */ - private blockLen = 0; - - constructor(handle: FileHandle, size: number, tempPath: string) { - this.handle = handle; - this.size = size; - this.tempPath = tempPath; - } - - async read(pos: number, len: number): Promise { - const out = Buffer.alloc(len); // zero-filled; out-of-range stays zero - const start = Math.max(pos, 0); - const end = Math.min(pos + len, this.size); - if (end <= start) { - return out; // window is entirely out of range — all zeros - } - - const need = end - start; - const outOffset = start - pos; - - if (need > OLD_READER_BLOCK_SIZE) { - // Window larger than a cache block — read straight into the output and - // leave the cache untouched (caching it would blow the memory bound). - await this.readExact(out, outOffset, need, start); - return out; - } - - if (!this.blockCovers(start, end)) { - await this.fillBlock(start); - } - out.set( - this.block.subarray(start - this.blockStart, end - this.blockStart), - outOffset - ); - return out; - } - - /** True when the cached block fully covers `[start, end)`. */ - private blockCovers(start: number, end: number): boolean { - return ( - this.blockStart >= 0 && - start >= this.blockStart && - end <= this.blockStart + this.blockLen - ); - } - - /** - * Refill the cache block starting at `start`. The length is clamped to the - * file size; callers only reach here when `[start, end)` fits in one block, - * and `start + len <= size`, so the read never crosses EOF. - */ - private async fillBlock(start: number): Promise { - const len = Math.min(OLD_READER_BLOCK_SIZE, this.size - start); - await this.readExact(this.block, 0, len, start); - this.blockStart = start; - this.blockLen = len; - } - - /** - * Read exactly `length` bytes at file offset `filePos` into `buf` at `offset`, - * looping over short positional reads (possible across some filesystems). - */ - private async readExact( - buf: Buffer, - offset: number, - length: number, - filePos: number - ): Promise { - let read = 0; - while (read < length) { - const { bytesRead } = await this.handle.read( - buf, - offset + read, - length - read, - filePos + read - ); - if (bytesRead === 0) { - break; // Unexpected EOF within bounds — leave remainder as-is - } - read += bytesRead; - } - } - - async close(): Promise { - if (this.closed) { - return; - } - this.closed = true; - try { - await this.handle.close(); - } catch { - // fd may already be closed — safe to ignore - } - await unlink(this.tempPath).catch(() => { - /* Best-effort cleanup — OS will reclaim on reboot */ - }); - } -} - -/** - * Open the old binary for on-demand read access during patching. - * - * Strategy: copy to a temp file, then read windows on demand via `pread`. The - * copy avoids ETXTBSY (Linux) / AMFI SIGKILL (macOS) issues with reading the - * running binary directly; on CoW filesystems (btrfs, xfs, APFS) it is a - * metadata-only reflink (near-instant). Reading on demand keeps the ~100 MB - * base out of the JS heap. - * - * Falls back to a full in-memory read of the original file if the copy or open - * fails (rare) — correctness over the memory optimization. - */ -let loadCounter = 0; - -async function loadOldBinary(oldPath: string): Promise { - loadCounter += 1; - const tempCopy = join( - tmpdir(), - `sentry-patch-old-${process.pid}-${loadCounter}` - ); - // Tracked outside the try so the catch can release a handle that was opened - // before a later step (e.g. stat) failed — otherwise the fd would leak. - let handle: FileHandle | undefined; - try { - // COPYFILE_FICLONE: attempt CoW reflink first (near-instant on btrfs/xfs/APFS), - // silently falls back to regular copy on filesystems that don't support it. - copyFileSync(oldPath, tempCopy, constants.COPYFILE_FICLONE); - handle = await open(tempCopy, "r"); - const { size } = await handle.stat(); - return new FileOldReader(handle, size, tempCopy); - } catch { - // Roll back any partially-acquired resources, then fall back to a direct - // in-memory read of the original. Close the handle first (if open() - // succeeded but a later step threw) so it isn't leaked, then drop the - // temp copy. - if (handle) { - await handle.close().catch(() => { - /* Already closed or never fully opened */ - }); - } - await unlink(tempCopy).catch(() => { - /* May not exist if copyFileSync failed */ - }); - return new MemoryOldReader(await readFile(oldPath)); - } -} - -/** - * Wrapping unsigned byte addition (`old + diff mod 256`) for a diff window. - * - * This is the hot inner loop of bspatch apply. Doing it byte-at-a-time in JS - * (two typed-array property accesses + a `% 256` per byte) is the dominant CPU - * cost on a large binary. We instead process 4 bytes per iteration with a - * SWAR (SIMD-within-a-register) add on `Uint32Array`: - * - * lows = (a & 0x7f7f7f7f) + (b & 0x7f7f7f7f) // top bit of each byte is 0, - * // so carries stay in-lane - * highs = (a ^ b) & 0x80808080 // high-bit carry per byte - * result = lows ^ highs // per-byte sum mod 256 - * - * This is carry-less (each byte lane sums independently mod 256) and is - * verified exhaustively (all byte values + every 4-byte alignment + tail 0-3). - * A short tail loop handles the trailing `n % 4` bytes. The old bytes are read - * as raw bytes (no `?? 0` per element) because the OldReader already zero-fills - * out-of-range positions. - * - * INVARIANT: the carry masks must match the word width. The low mask clears - * the top bit of EVERY byte lane and the high mask isolates the top bit of - * EVERY lane — both are 0x7f7f7f7f / 0x80808080 because words are 32-bit. A - * wider-word (BigUint64Array) variant must widen BOTH masks to 64-bit - * (0x7f7f7f7f7f7f7f7f / 0x8080808080808080); pairing 64-bit words with the - * 32-bit carry mask silently drops carries in the upper 4 bytes and corrupts - * the output (caught by the exhaustive tests). - */ -export function addDiffChunk( - output: Uint8Array, - oldChunk: Uint8Array, - diffChunk: Uint8Array, - n: number -): void { - // The SWAR fast path reinterprets each buffer as Uint32Array, which requires - // a 4-byte-aligned byteOffset — `new Uint32Array(buf.buffer, byteOffset)` - // throws RangeError otherwise. Today all callers pass fresh, offset-0 buffers - // (new Uint8Array(len) / Buffer.alloc(len)), but a future caller could pass a - // pooled or subarray view. Rather than throw (this is a perf detail that must - // never break apply), fall back to the byte loop when any buffer is - // misaligned. Correct for all inputs; the SWAR path is a pure optimization. - const aligned = - output.byteOffset % 4 === 0 && - oldChunk.byteOffset % 4 === 0 && - diffChunk.byteOffset % 4 === 0; - - const words = aligned ? Math.floor(n / 4) : 0; - if (words > 0) { - const oldWords = new Uint32Array( - oldChunk.buffer, - oldChunk.byteOffset, - words - ); - const diffWords = new Uint32Array( - diffChunk.buffer, - diffChunk.byteOffset, - words - ); - const outWords = new Uint32Array(output.buffer, output.byteOffset, words); - const LOW = 0x7f_7f_7f_7f; - const HIGH = 0x80_80_80_80; - for (let i = 0; i < words; i++) { - const a = oldWords[i] ?? 0; - const b = diffWords[i] ?? 0; - // biome-ignore lint/suspicious/noBitwiseOperators: SWAR per-lane add uses bitmask/xor by design - const sum = ((a & LOW) + (b & LOW)) ^ ((a ^ b) & HIGH); - // biome-ignore lint/suspicious/noBitwiseOperators: coerce to uint32 - outWords[i] = sum >>> 0; - } - } - const tailStart = words * 4; - for (let i = tailStart; i < n; i++) { - output[i] = ((oldChunk[i] ?? 0) + (diffChunk[i] ?? 0)) % 256; - } -} - -/** - * Core TRDIFF10 transform. - * - * Applies a patch to in-memory old bytes, emitting output chunks in order via - * `onChunk`. Handles header parsing, streaming zstd decompression of the diff - * and extra blocks, the wrapping-add reconstruction, and output-size validation. - * - * The base bytes are pulled from `oldReader` one diff-window at a time (the - * algorithm only references the old binary during the diff step), so the caller - * decides whether they come from disk or memory. Output routing is likewise the - * caller's choice: `onChunk` writes to disk, hashes, and/or collects into a - * buffer. The diff/extra decompression readers are always cancelled before - * returning, even on error. `onChunk` may throw to abort the transform early - * (used to surface a streaming write failure). - * - * @param oldReader - Random-access view of the base ("old") binary - * @param patchData - Complete TRDIFF10 patch file contents - * @param onChunk - Receives each output chunk in order; may throw to abort - * @throws {Error} On corrupt patch, or when output size disagrees with the header - */ -async function transformPatch( - oldReader: OldReader, - patchData: Uint8Array, - onChunk: (chunk: Uint8Array) => void -): Promise { - const { controlLen, diffLen, newSize } = parsePatchHeader(patchData); - - // Slice compressed blocks from the patch buffer - const controlStart = HEADER_SIZE; - const diffStart = controlStart + controlLen; - const extraStart = diffStart + diffLen; - - // Control block is tiny — decompress fully for random access to tuples - const controlBlock = zstdDecompressSync( - patchData.subarray(controlStart, diffStart) - ); - - // Diff and extra blocks are streamed — only a few KB in memory at a time - const diffReader = createZstdStreamReader( - patchData.subarray(diffStart, extraStart) - ); - const extraReader = createZstdStreamReader(patchData.subarray(extraStart)); - - let oldpos = 0; - let newpos = 0; - - try { - // Process control entries: each is 3 × i64 = 24 bytes - for ( - let controlPos = 0; - controlPos < controlBlock.byteLength; - controlPos += 24 - ) { - const readDiffBy = offtin(controlBlock, controlPos); - const readExtraBy = offtin(controlBlock, controlPos + 8); - const seekBy = offtin(controlBlock, controlPos + 16); - - // Step 1: Read diff bytes and add to old file bytes (wrapping u8 add) - if (readDiffBy > 0) { - const diffChunk = await diffReader.read(readDiffBy); - // Pull exactly the old-file window this step references (zero-filled - // beyond the file's bounds — see OldReader.read). - const oldChunk = await oldReader.read(oldpos, readDiffBy); - const outputChunk = new Uint8Array(readDiffBy); - - // Wrapping unsigned byte addition, matching zig-bsdiff's @addWithOverflow. - // SWAR on Uint32Array — see addDiffChunk. - addDiffChunk(outputChunk, oldChunk, diffChunk, readDiffBy); - - onChunk(outputChunk); - oldpos += readDiffBy; - newpos += readDiffBy; - } - - // Step 2: Copy extra bytes directly to output (new data) - if (readExtraBy > 0) { - const extraChunk = await extraReader.read(readExtraBy); - onChunk(extraChunk); - newpos += readExtraBy; - } - - // Step 3: Seek old file position - oldpos += seekBy; - } - } finally { - await Promise.all([diffReader.cancel(), extraReader.cancel()]); - } - - // Validate output size matches header - if (newpos !== newSize) { - throw new Error( - `Output size mismatch: wrote ${newpos} bytes, expected ${newSize}` - ); - } -} - -/** - * Apply a patch to the base bytes from `oldReader`, streaming the result to - * `destPath` while computing its SHA-256. - * - * Used for the final hop of a chain (and single-patch upgrades), where the - * output must be persisted and verified. - * - * @param oldReader - Random-access view of the base ("old") binary - * @param patchData - Complete TRDIFF10 patch file contents - * @param destPath - Path to write the patched output - * @returns SHA-256 hex digest of the written output - * @throws {Error} On corrupt patch, I/O failure, or size mismatch - */ -async function applyReaderToFile( - oldReader: OldReader, - patchData: Uint8Array, - destPath: string, - onBytes?: (bytes: number) => void -): Promise { - const writer = createWriteStream(destPath); - const hasher = createHash("sha256"); - - // Capture write errors early — without a listener, Node crashes with - // ERR_UNHANDLED_ERROR if a write fails (ENOSPC, EIO, etc.) during the loop. - let writeError: Error | undefined; - writer.on("error", (err) => { - writeError ??= err; - }); - - try { - await transformPatch(oldReader, patchData, (chunk) => { - // Abort the transform on the first I/O failure. Throwing here unwinds - // through transformPatch's reader cleanup; the writer is then flushed - // and the error re-surfaced in the finally below. - if (writeError) { - throw writeError; - } - writer.write(chunk); - hasher.update(chunk); - onBytes?.(chunk.byteLength); - }); - } finally { - await new Promise((resolve, reject) => { - writer.end((err?: Error | null) => { - const finalErr = err ?? writeError; - if (finalErr) { - reject(finalErr); - } else { - resolve(); - } - }); - }); - } - - return hasher.digest("hex"); -} - -/** - * Apply a patch to the base bytes from `oldReader`, returning the result as a - * new in-memory buffer. - * - * Used for the intermediate hops of a multi-patch chain: the output becomes the - * base for the next patch without ever touching disk, and no SHA-256 is computed - * (only the final binary is hashed and verified). - * - * @param oldReader - Random-access view of the base ("old") binary - * @param patchData - Complete TRDIFF10 patch file contents - * @returns The patched output bytes - * @throws {Error} On corrupt patch or size mismatch - */ -async function applyReaderToMemory( - oldReader: OldReader, - patchData: Uint8Array, - onBytes?: (bytes: number) => void -): Promise { - // Preallocate the exact output size from the header so chunks can be copied - // in place — avoids a final concat pass over ~100 MB of output. - const { newSize } = parsePatchHeader(patchData); - const output = new Uint8Array(newSize); - let offset = 0; - - await transformPatch(oldReader, patchData, (chunk) => { - output.set(chunk, offset); - offset += chunk.byteLength; - onBytes?.(chunk.byteLength); - }); - - return output; -} - -/** - * Apply a patch to in-memory old bytes, returning the result as a new buffer. - * - * Convenience wrapper over the internal reader-based path for callers that - * already hold the base bytes in memory (e.g. tests). Production chains use - * {@link applyPatchChainInMemory}, which reads the on-disk base on demand. - * - * @param oldFile - Full contents of the base ("old") binary - * @param patchData - Complete TRDIFF10 patch file contents - * @returns The patched output bytes - * @throws {Error} On corrupt patch or size mismatch - */ -export function applyPatchToMemory( - oldFile: Uint8Array, - patchData: Uint8Array -): Promise { - return applyReaderToMemory(new MemoryOldReader(oldFile), patchData); -} - -/** - * Apply a sequence of TRDIFF10 patches, oldest first, writing the final binary - * to `destPath` and returning its SHA-256. - * - * The base binary at `oldPath` is loaded once (copied to a temp file to avoid - * reading the running executable in place — see {@link loadOldBinary}). Every - * intermediate result is kept in memory and fed straight into the next patch, - * so intermediates never hit disk and only the final binary is hashed. This - * eliminates the N−1 redundant disk writes, temp-copies, and SHA-256 passes a - * file-by-file chain would incur — and because reads and writes never target - * the same path, there is no risk of truncating a file that is being read. - * - * For a single-patch chain this is equivalent to applying that patch straight - * to `destPath`. - * - * @param oldPath - Path to the base ("old") binary - * @param patches - Patches to apply in order (oldest first); must be non-empty - * @param destPath - Path to write the final patched binary - * @returns SHA-256 hex digest of the final output - * @throws {Error} When `patches` is empty, or on corrupt patch / I/O / size mismatch - */ -export async function applyPatchChainInMemory( - oldPath: string, - patches: Uint8Array[], - destPath: string, - onBytes?: (bytes: number) => void -): Promise { - if (patches.length === 0) { - throw new Error("Cannot apply an empty patch chain"); - } - - // First hop reads the on-disk base on demand (fd-backed). Subsequent hops - // read the previous in-memory output. Each reader is closed before the next - // replaces it; the active one is closed in the finally. - let reader = await loadOldBinary(oldPath); - - try { - // Intermediate hops stay entirely in memory — no disk I/O, no hashing. - for (let i = 0; i < patches.length - 1; i++) { - const patch = patches[i]; - if (!patch) { - throw new Error(`Missing patch at index ${i}`); - } - const next = await applyReaderToMemory(reader, patch, onBytes); - await reader.close(); - reader = new MemoryOldReader(next); - } - - // Final hop streams to disk and computes the verification hash. - const finalPatch = patches.at(-1); - if (!finalPatch) { - throw new Error("Missing final patch"); - } - return await applyReaderToFile(reader, finalPatch, destPath, onBytes); - } finally { - await reader.close(); - } -} - -/** - * Apply a single TRDIFF10 binary patch and write the result to `destPath`. - * - * Thin wrapper over {@link applyPatchChainInMemory} for the common single-patch - * case; preserved as the documented entry point for one-shot patch application. - * - * @param oldPath - Path to the existing (old) binary file - * @param patchData - Complete TRDIFF10 patch file contents - * @param destPath - Path to write the patched (new) binary - * @returns SHA-256 hex digest of the written output - * @throws {Error} On corrupt patch, I/O failure, or size mismatch - */ -export function applyPatch( - oldPath: string, - patchData: Uint8Array, - destPath: string -): Promise { - return applyPatchChainInMemory(oldPath, [patchData], destPath); -} diff --git a/src/lib/delta-upgrade.ts b/src/lib/delta-upgrade.ts index 6225bcdb58..66cae084df 100644 --- a/src/lib/delta-upgrade.ts +++ b/src/lib/delta-upgrade.ts @@ -1,532 +1,222 @@ -/** - * Delta Upgrade Module - * - * Discovers and applies binary delta patches for CLI self-upgrades. - * Instead of downloading the full ~30 MB gzipped binary, downloads - * tiny patches (50-500 KB) and applies them to the currently installed - * binary using the TRDIFF10 format (zig-bsdiff with zstd compression). - * - * Supports two channels: - * - **Stable**: patches stored as GitHub Release assets with predictable names - * - **Nightly**: patches stored in GHCR with `:patch-` tags - * - * Falls back to full download when: - * - No patch is available (404) - * - Chain of patches exceeds 60% of the full download size - * - Chain exceeds the maximum depth (10 steps) - * - Any error occurs during patch download or application - */ +/** Delta upgrade discovery and application backed by binpatch. */ +import { join } from "node:path"; // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; -import { compare as semverCompare, valid as semverValid } from "semver"; - import { + applyPatchChainInMemory, + extractStableChain as binpatchExtractStableChain, + filterAndSortChainTags as binpatchFilterAndSortChainTags, + validateChainStep as binpatchValidateChainStep, + type DeltaTelemetry, + type DeltaUnavailableReason, + type ExtractStableChainOpts, + type GitHubRelease, + getPatchFromVersion, + getPatchTargetSha256, + ghcrSource, + githubReleaseSource, + type InstrumentHook, + MAX_NIGHTLY_CHAIN_DEPTH, + makeCache, + OciClient, + type OciManifest, + PATCH_TAG_PREFIX, + type PatchCache, + type PatchChain, + type ProgressHandler, + resolveAndApply, + SIZE_THRESHOLD_RATIO, + type SourceStrategy, + type StableChainInfo, +} from "binpatch"; +import { + compareVersions, GITHUB_RELEASES_URL, getPlatformBinaryName, isDowngrade, isNightlyVersion, } from "./binary.js"; -import { applyPatchChainInMemory, parsePatchHeader } from "./bspatch.js"; import { CLI_VERSION } from "./constants.js"; import { customFetch } from "./custom-ca.js"; +import { getConfigDir } from "./db/index.js"; import { formatBytes } from "./formatters/numbers.js"; -import { - downloadLayerBlob, - fetchManifest, - getAnonymousToken, - listTags, - type OciManifest, -} from "./ghcr.js"; import { logger } from "./logger.js"; -import { loadCachedChain, savePatchesToCache } from "./patch-cache.js"; import { makeByteProgress, type SetMessage } from "./progress.js"; import { withTracing, withTracingSpan } from "./telemetry.js"; -/** Scoped logger for delta upgrade operations */ -const log = logger.withTag("delta-upgrade"); +export type { + ExtractStableChainOpts, + GitHubAsset, + GitHubRelease, + PatchChain, + StableChainInfo, +} from "binpatch"; +// biome-ignore lint/performance/noBarrelFile: preserve the existing public API +export { + extractSha256, + getPatchFromVersion, + getPatchTargetSha256, + getStableTargetSha256, + PATCH_TAG_PREFIX, +} from "binpatch"; -/** - * Maximum number of stable patches to chain before falling back to full download. - * Also controls the GitHub Releases `per_page` in {@link fetchRecentReleases}. - */ -const MAX_STABLE_CHAIN_DEPTH = 10; +export type DeltaResult = { + sha256: string; + patchBytes: number; + chainLength: number; +}; -/** - * Maximum number of nightly patches to chain before falling back to full download. - * Matches the GHCR cleanup `KEEP_COUNT` (30 retained nightly tags) so every - * retained patch tag is usable. Acts as a safety net against unbounded manifest - * fan-out — the size budget ({@link SIZE_THRESHOLD_RATIO}) is the primary guard. - */ -const MAX_NIGHTLY_CHAIN_DEPTH = 30; +const GITHUB_REPO_REPO_NAME = "getsentry/sentry-cli"; +const log = logger.withTag("delta-upgrade"); -/** - * Maximum ratio of total patch chain size to full download size. - * If the sum of patches exceeds this fraction of the `.gz` download, - * we fall back to full download since the savings are too small. - */ -const SIZE_THRESHOLD_RATIO = 0.6; +const instrument: InstrumentHook = (name, fn) => + withTracing(name, "http.client", fn); -/** Pattern to extract hex from a GitHub asset digest like "sha256:" */ -const SHA256_DIGEST_PATTERN = /^sha256:([0-9a-f]+)$/i; +function patchCacheKey(fromVersion: string, toVersion: string): string { + return `patch-chain:${fromVersion}-${toVersion}`; +} -/** A single link in the patch chain */ -type PatchLink = { - /** Raw patch file data */ - data: Uint8Array; - /** Byte size of the patch */ - size: number; -}; +function instrumentCache(base: PatchCache): PatchCache { + return { + load(currentVersion, targetVersion) { + const key = patchCacheKey(currentVersion, targetVersion); + return withTracingSpan(key, "cache.get", async (span) => { + span.setAttribute("cache.key", [key]); + const result = await base.load(currentVersion, targetVersion); + span.setAttribute("cache.hit", result !== null); + if (result) { + span.setAttribute("cache.item_size", result.totalSize); + } + return result; + }); + }, + save(chain, steps) { + const first = steps.at(0); + const last = steps.at(-1); + if (!(first && last)) { + return base.save(chain, steps); + } + const key = patchCacheKey(first.fromVersion, last.toVersion); + return withTracingSpan(key, "cache.put", async (span) => { + span.setAttribute("cache.key", [key]); + span.setAttribute( + "cache.item_size", + chain.patches.reduce((sum, patch) => sum + patch.size, 0) + ); + await base.save(chain, steps); + }); + }, + cleanup: () => base.cleanup(), + clear: () => base.clear(), + }; +} -/** A resolved chain of patches from current version to target version */ -export type PatchChain = { - /** Ordered list of patches to apply (oldest first) */ - patches: PatchLink[]; - /** Total size of all patches in the chain (bytes) */ - totalSize: number; - /** Expected SHA-256 hex digest of the final output binary */ - expectedSha256: string; - /** - * Version step pairs in apply order (oldest first). - * Present when the chain was resolved from network — used for cache storage. - */ - steps?: { fromVersion: string; toVersion: string }[]; -}; +function getPatchCache(): PatchCache { + return instrumentCache(makeCache(join(getConfigDir(), "patch-cache"))); +} -/** Result of a successful delta upgrade */ -export type DeltaResult = { - /** SHA-256 hex digest of the output binary */ - sha256: string; - /** Total bytes downloaded for the patch chain */ - patchBytes: number; - /** Number of patches in the chain (1 = direct, >1 = multi-hop) */ - chainLength: number; -}; +function stableSource(): SourceStrategy { + return githubReleaseSource({ + releasesUrl: GITHUB_RELEASES_URL, + binaryName: getPlatformBinaryName(), + userAgent: `sentry-cli/${CLI_VERSION}`, + fetch: customFetch, + instrument, + }); +} + +function nightlySource(): SourceStrategy { + return ghcrSource({ + registry: "https://ghcr.io", + repo: GITHUB_REPO_REPO_NAME, + binaryName: getPlatformBinaryName(), + targetTag: (version) => `nightly-${version}`, + compareVersions, + userAgent: `sentry-cli/${CLI_VERSION}`, + fetch: customFetch, + instrument, + }); +} -/** - * Check whether delta upgrade can be attempted. - * - * Conditions that prevent delta upgrade: - * - Running a dev build (CLI_VERSION = "0.0.0-dev") - * - Cross-channel upgrade (stable→nightly or nightly→stable) - * - Current executable path is not readable - * - * @param targetVersion - Version to upgrade to - * @returns true if delta upgrade should be attempted - */ export function canAttemptDelta(targetVersion: string): boolean { - // Dev builds have no known base version to patch from if (CLI_VERSION === "0.0.0-dev") { return false; } - - // Cross-channel upgrades are rare one-off operations; skip delta if (isNightlyVersion(CLI_VERSION) !== isNightlyVersion(targetVersion)) { return false; } - - // Downgrades have no forward patch path — skip immediately - if (isDowngrade(CLI_VERSION, targetVersion)) { - return false; - } - - return true; + return !isDowngrade(CLI_VERSION, targetVersion); } -// Stable channel: GitHub Releases - -/** GitHub Release asset metadata (subset of API response) */ -export type GitHubAsset = { - name: string; - size: number; - /** SHA-256 digest in the form "sha256:" */ - digest?: string; - browser_download_url: string; -}; - -/** GitHub Release metadata (subset of API response) */ -export type GitHubRelease = { - tag_name: string; - assets: GitHubAsset[]; - /** Markdown release notes body. May be empty or absent for pre-releases. */ - body?: string; -}; - -/** - * Fetch recent releases from GitHub, ordered newest-first. - * - * A single API call returns full release metadata including assets, - * eliminating the need for per-release fetches during chain resolution. - * - * @returns Array of releases (newest first), or empty array on failure - */ export async function fetchRecentReleases( signal?: AbortSignal ): Promise { - const perPage = MAX_STABLE_CHAIN_DEPTH + 2; - let response: Response; try { - response = await customFetch(`${GITHUB_RELEASES_URL}?per_page=${perPage}`, { + const response = await customFetch(`${GITHUB_RELEASES_URL}?per_page=12`, { headers: { Accept: "application/vnd.github.v3+json", - "User-Agent": "sentry-cli", + "User-Agent": `sentry-cli/${CLI_VERSION}`, }, signal, }); + return response.ok ? ((await response.json()) as GitHubRelease[]) : []; } catch (error) { log.debug("Failed to fetch recent releases from GitHub", error); return []; } - if (!response.ok) { - return []; - } - return (await response.json()) as GitHubRelease[]; -} - -/** - * Extract SHA-256 hex digest from a GitHub asset's digest field. - * - * GitHub provides digests as "sha256:". This strips the prefix. - * - * @param asset - GitHub Release asset - * @returns Hex digest string, or null if no digest available - */ -export function extractSha256(asset: GitHubAsset): string | null { - if (!asset.digest) { - return null; - } - const match = SHA256_DIGEST_PATTERN.exec(asset.digest); - // Normalize to lowercase — Bun.CryptoHasher.digest("hex") returns lowercase - return match ? (match[1]?.toLowerCase() ?? null) : null; } -/** - * Download a patch file from a GitHub Release asset URL. - * - * @param url - Browser download URL for the asset - * @returns Patch file data, or null on failure - */ export async function downloadStablePatch( url: string, signal?: AbortSignal ): Promise { - let response: Response; try { - response = await customFetch(url, { - headers: { "User-Agent": "sentry-cli" }, + const response = await customFetch(url, { + headers: { "User-Agent": `sentry-cli/${CLI_VERSION}` }, signal, }); + return response.ok ? new Uint8Array(await response.arrayBuffer()) : null; } catch (error) { log.debug("Failed to download stable patch", error); return null; } - if (!response.ok) { - return null; - } - return new Uint8Array(await response.arrayBuffer()); } -/** - * Extract the target binary SHA-256 from a GitHub Release. - * - * @param release - GitHub Release metadata - * @param binaryName - Platform binary name (e.g., "sentry-linux-x64") - * @returns Hex SHA-256 digest, or null if unavailable - */ -export function getStableTargetSha256( - release: GitHubRelease, - binaryName: string -): string | null { - const binaryAsset = release.assets.find((a) => a.name === binaryName); - if (!binaryAsset) { - return null; - } - return extractSha256(binaryAsset); -} - -/** Options for extracting the stable version chain from a release list */ -export type ExtractStableChainOpts = { - releases: GitHubRelease[]; - currentVersion: string; - targetVersion: string; - binaryName: string; - fullGzSize: number; -}; - -/** Extracted stable chain info (patch URLs in apply order + target hash) */ -export type StableChainInfo = { - /** Patch download URLs in apply order (oldest patch first) */ - patchUrls: string[]; - /** Expected SHA-256 of the final target binary */ - expectedSha256: string; - /** Version step pairs in apply order (oldest first) */ - steps: { fromVersion: string; toVersion: string }[]; -}; - -/** - * Extract the chain of patch URLs from an already-fetched release list. - * - * Pure computation over the release array — no HTTP calls. Validates that - * every release in the chain has a patch asset and that the cumulative - * size stays under the threshold. - * - * @returns Chain info with URLs in apply order, or null if unavailable - */ export function extractStableChain( opts: ExtractStableChainOpts ): StableChainInfo | null { - const { releases, currentVersion, targetVersion, binaryName, fullGzSize } = - opts; - const patchAssetName = `${binaryName}.patch`; - - // Releases are newest-first; find target and current positions - const targetIdx = releases.findIndex((r) => r.tag_name === targetVersion); - const currentIdx = releases.findIndex((r) => r.tag_name === currentVersion); - if (targetIdx === -1 || currentIdx === -1 || targetIdx >= currentIdx) { - return null; - } - - // Chain: [target, ..., current+1] (newest first, excludes current) - const chainReleases = releases.slice(targetIdx, currentIdx); - if (chainReleases.length > MAX_STABLE_CHAIN_DEPTH) { - log.debug( - `Stable chain depth ${chainReleases.length} exceeds limit ${MAX_STABLE_CHAIN_DEPTH}` - ); - return null; - } - - // SHA-256 comes from the target release's binary asset - const targetRelease = chainReleases[0]; - if (!targetRelease) { - return null; - } - const expectedSha256 = getStableTargetSha256(targetRelease, binaryName) ?? ""; - if (!expectedSha256) { - return null; - } - - // Collect patch URLs and validate size threshold - const patchUrls: string[] = []; - let totalSize = 0; - for (const release of chainReleases) { - const patchAsset = release.assets.find((a) => a.name === patchAssetName); - if (!patchAsset) { - return null; - } - patchUrls.push(patchAsset.browser_download_url); - totalSize += patchAsset.size; - if (totalSize > fullGzSize * SIZE_THRESHOLD_RATIO) { - log.debug( - `Stable chain size ${totalSize} exceeds ${Math.round(SIZE_THRESHOLD_RATIO * 100)}% of full download ${fullGzSize}` - ); - return null; - } - } - - // Reverse to get apply order: oldest patch first - patchUrls.reverse(); - - // Build version steps in apply order (oldest first, matching patchUrls) - const reversedReleases = [...chainReleases].reverse(); - const steps: { fromVersion: string; toVersion: string }[] = []; - let prevVersion = currentVersion; - for (const release of reversedReleases) { - steps.push({ fromVersion: prevVersion, toVersion: release.tag_name }); - prevVersion = release.tag_name; - } - - return { patchUrls, expectedSha256, steps }; + const result = binpatchExtractStableChain(opts); + return "failure" in result ? null : result; } -/** - * Resolve a chain of stable patches from current to target version. - * - * 1. Single API call: fetch recent releases (includes full asset metadata) - * 2. Extract chain info from the list (pure computation, no I/O) - * 3. Parallel: download all patch files concurrently - * - * @param currentVersion - Currently installed version - * @param targetVersion - Version to upgrade to - * @returns Resolved patch chain, or null if unavailable - */ -export async function resolveStableChain( - currentVersion: string, - targetVersion: string, - signal?: AbortSignal -): Promise { - const binaryName = getPlatformBinaryName(); - const releases = await withTracing("fetch-releases", "http.client", () => - fetchRecentReleases(signal) - ); - - // Get .gz size from the target release for threshold calculation - const targetRelease = releases.find((r) => r.tag_name === targetVersion); - if (!targetRelease) { - return null; - } - const gzAsset = targetRelease.assets.find( - (a) => a.name === `${binaryName}.gz` - ); - if (!gzAsset) { - return null; - } - - const chainInfo = extractStableChain({ - releases, - currentVersion, - targetVersion, - binaryName, - fullGzSize: gzAsset.size, - }); - if (!chainInfo) { - return null; - } - - // Parallel patch download - const downloadResults = await withTracing( - "download-patches", - "http.client", - () => - Promise.all( - chainInfo.patchUrls.map((url) => downloadStablePatch(url, signal)) - ) - ); - - const patches: PatchLink[] = []; - let totalSize = 0; - for (const data of downloadResults) { - if (!data) { - return null; - } - patches.push({ data, size: data.byteLength }); - totalSize += data.byteLength; - } - - return { - patches, - totalSize, - expectedSha256: chainInfo.expectedSha256, - steps: chainInfo.steps, - }; -} - -// Nightly channel: GHCR - -/** - * Extract the `from-version` annotation from a patch manifest. - * - * @param manifest - OCI manifest for a `:patch-` tag - * @returns The base version this patch applies to, or null if missing - */ -export function getPatchFromVersion(manifest: OciManifest): string | null { - return manifest.annotations?.["from-version"] ?? null; -} - -/** - * Extract the SHA-256 annotation for a specific platform from a patch manifest. - * - * Annotations are stored as `sha256-=`. - * - * @param manifest - OCI manifest for a `:patch-` tag - * @param binaryName - Platform binary name (e.g., "sentry-linux-x64") - * @returns Hex digest string, or null if not found - */ -export function getPatchTargetSha256( - manifest: OciManifest, - binaryName: string -): string | null { - return manifest.annotations?.[`sha256-${binaryName}`] ?? null; -} - -/** GHCR tag prefix for patch manifests */ -export const PATCH_TAG_PREFIX = "patch-"; - -/** - * Filter patch tags to only those in the upgrade chain from current to target, - * and sort them in apply order (oldest first). - * - * Since nightly patches are sequential (each `patch-` patches from V_{n-1}), - * the chain tags are those where the version is strictly greater than - * currentVersion and less than or equal to targetVersion. - * - * @param allTags - All patch tags from GHCR (e.g., `["patch-0.14.0-dev.100", ...]`) - * @param currentVersion - Version to upgrade from - * @param targetVersion - Version to upgrade to - * @returns Sorted tag names in apply order, or empty array if none match - */ export function filterAndSortChainTags( allTags: string[], currentVersion: string, targetVersion: string ): string[] { - if (!(semverValid(currentVersion) && semverValid(targetVersion))) { - return []; - } - - const chainTags: { tag: string; version: string }[] = []; - - for (const tag of allTags) { - const version = tag.slice(PATCH_TAG_PREFIX.length); - if (!semverValid(version)) { - continue; - } - // Include tags where: currentVersion < version <= targetVersion - if ( - semverCompare(version, currentVersion) === 1 && - semverCompare(version, targetVersion) !== 1 - ) { - chainTags.push({ tag, version }); - } - } - - // Sort by version (chronological for nightlies) - chainTags.sort((a, b) => semverCompare(a.version, b.version)); - - return chainTags.map((t) => t.tag); + return binpatchFilterAndSortChainTags( + allTags, + currentVersion, + targetVersion, + compareVersions + ); } -/** Result of validating a nightly chain of manifests */ -type NightlyChainValidation = { - /** Layer digests in apply order (oldest first) */ - digests: string[]; - /** Total size of all patch layers */ - totalSize: number; - /** Expected SHA-256 of the final target binary */ - expectedSha256: string; -}; - -/** Options for validating a nightly chain */ -type ValidateChainOpts = { - manifests: OciManifest[]; - chainTags: string[]; - currentVersion: string; - targetVersion: string; - patchLayerName: string; - binaryName: string; - fullGzSize: number; -}; - -/** Reason a chain step failed validation */ -type ChainStepFailure = - | { reason: "version-mismatch"; expected: string; actual: string | null } - | { reason: "missing-layer"; layerName: string } - | { reason: "size-exceeded"; layerSize: number; budget: number }; - -/** Result of validating a single chain step */ type ChainStepResult = | { ok: true; digest: string; size: number } - | { ok: false; failure: ChainStepFailure }; + | { + ok: false; + failure: + | { + reason: "version-mismatch"; + expected: string; + actual: string | null; + } + | { reason: "missing-layer"; layerName: string } + | { reason: "size-exceeded"; layerSize: number; budget: number }; + }; -/** - * Validate a single step in the nightly chain. - * - * Checks three conditions in order: - * 1. Manifest's `from-version` annotation matches the expected previous version - * 2. A layer with the expected platform title exists in the manifest - * 3. The layer's size fits within the remaining download budget - * - * @returns Layer digest and size on success, or a typed failure reason - */ export function validateChainStep( manifest: OciManifest, opts: { expectedFrom: string; patchLayerName: string; sizeLimit: number } @@ -542,568 +232,235 @@ export function validateChainStep( }, }; } - - const layer = manifest.layers.find((l) => { - const title = l.annotations?.["org.opencontainers.image.title"]; - return title === opts.patchLayerName; - }); - if (!layer) { - return { - ok: false, - failure: { reason: "missing-layer", layerName: opts.patchLayerName }, - }; - } - if (layer.size > opts.sizeLimit) { - return { - ok: false, - failure: { - reason: "size-exceeded", - layerSize: layer.size, - budget: opts.sizeLimit, - }, - }; + const result = binpatchValidateChainStep(manifest, opts); + if (result.ok) { + return result; } + const layer = manifest.layers.find( + (item) => + item.annotations?.["org.opencontainers.image.title"] === + opts.patchLayerName + ); + return layer + ? { + ok: false, + failure: { + reason: "size-exceeded", + layerSize: layer.size, + budget: opts.sizeLimit, + }, + } + : { + ok: false, + failure: { reason: "missing-layer", layerName: opts.patchLayerName }, + }; +} - return { ok: true, digest: layer.digest, size: layer.size }; +export function resolveStableChain( + currentVersion: string, + targetVersion: string, + signal?: AbortSignal +): Promise { + return stableSource().resolveChain(currentVersion, targetVersion, signal); } -/** Format a chain step failure reason for debug logging */ -function formatStepFailure(failure: ChainStepFailure): string { - switch (failure.reason) { - case "version-mismatch": - return `version mismatch (expected=${failure.expected}, actual=${failure.actual ?? "missing"})`; - case "missing-layer": - return `platform layer not found (expected=${failure.layerName})`; - case "size-exceeded": - return `patch too large (size=${failure.layerSize}, budget=${failure.budget})`; - default: - return "unknown failure"; +export async function resolveNightlyChain(opts: { + token: string; + currentVersion: string; + targetVersion: string; + fullGzSize: number; + preloadedTags?: string[]; + signal?: AbortSignal; +}): Promise { + const client = new OciClient({ + registry: "https://ghcr.io", + repo: GITHUB_REPO_REPO_NAME, + userAgent: `sentry-cli/${CLI_VERSION}`, + fetch: customFetch, + }); + const tags = + opts.preloadedTags ?? + (await client.listTags(opts.token, PATCH_TAG_PREFIX, opts.signal)); + const chainTags = filterAndSortChainTags( + tags, + opts.currentVersion, + opts.targetVersion + ); + if (chainTags.length === 0 || chainTags.length > MAX_NIGHTLY_CHAIN_DEPTH) { + return null; } -} -/** - * Validate a chain of manifests and extract patch layer info. - * - * Checks that each manifest's `from-version` links to the previous step, - * that the platform patch layer exists, and that cumulative size stays - * under the threshold. - * - * @returns Validated chain info, or null if the chain is invalid - */ -function validateNightlyChain( - opts: ValidateChainOpts -): NightlyChainValidation | null { - const { - manifests, - chainTags, - currentVersion, - targetVersion, - patchLayerName, - binaryName, - fullGzSize, - } = opts; + let manifests: OciManifest[]; + try { + manifests = await Promise.all( + chainTags.map((tag) => client.fetchManifest(opts.token, tag, opts.signal)) + ); + } catch { + return null; + } + const binaryName = getPlatformBinaryName(); + const patchLayerName = `${binaryName}.patch`; const digests: string[] = []; + const steps: { fromVersion: string; toVersion: string }[] = []; + let previousVersion = opts.currentVersion; let totalSize = 0; - let prevVersion = currentVersion; + let expectedSha256 = ""; - for (let i = 0; i < manifests.length; i++) { - const manifest = manifests[i]; - const tag = chainTags[i]; + for (const [index, manifest] of manifests.entries()) { + const tag = chainTags[index]; if (!(manifest && tag)) { return null; } - - const remainingBudget = fullGzSize * SIZE_THRESHOLD_RATIO - totalSize; + // Use the local validateChainStep (not binpatch's) so the rich 3-reason + // telemetry classification (version-mismatch | missing-layer | + // size-exceeded) survives the binpatch adoption. binpatch's returns a + // coarser {ok:false, reason: "malformed" | "over_budget"}. const result = validateChainStep(manifest, { - expectedFrom: prevVersion, + expectedFrom: previousVersion, patchLayerName, - sizeLimit: remainingBudget, + sizeLimit: opts.fullGzSize * SIZE_THRESHOLD_RATIO - totalSize, }); if (!result.ok) { - log.debug( - `Nightly chain step ${i + 1} failed: ${formatStepFailure(result.failure)}` + Sentry.getActiveSpan()?.setAttribute( + "telemetry_reason", + result.failure.reason ); return null; } - + const toVersion = tag.slice(PATCH_TAG_PREFIX.length); digests.push(result.digest); totalSize += result.size; - prevVersion = tag.slice(PATCH_TAG_PREFIX.length); - - if (i === manifests.length - 1) { - // Verify the last tag actually corresponds to the target version. - // Without this check, a missing patch- tag could - // cause the chain to silently stop at an intermediate version. - if (prevVersion !== targetVersion) { - return null; - } - const sha256 = getPatchTargetSha256(manifest, binaryName) ?? ""; - if (!sha256) { - return null; - } - return { digests, totalSize, expectedSha256: sha256 }; - } - } - - return null; -} - -/** Options for fetching chain manifests */ -type FetchManifestsOpts = { - token: string; - tags: string[]; - allTagCount: number; - chainTagCount: number; - signal?: AbortSignal; -}; - -/** - * Fetch manifests for the given chain tags. - * - * @returns Map from tag → manifest for the tags that were fetched - */ -async function fetchChainManifests( - opts: FetchManifestsOpts -): Promise> { - const { token, tags, allTagCount, chainTagCount, signal } = opts; - const fetchedManifests = new Map(); - - const results = await withTracingSpan( - "fetch-chain-manifests", - "http.client", - (span) => { - span.setAttribute("chain.tags_total", allTagCount); - span.setAttribute("chain.tags_filtered", chainTagCount); - span.setAttribute("chain.fetched_count", tags.length); - - return Promise.all( - tags.map(async (tag) => { - try { - const manifest = await fetchManifest(token, tag, signal); - return { tag, manifest }; - } catch { - return { tag, manifest: null }; - } - }) - ); - } - ); - - for (const { tag, manifest } of results) { - if (manifest) { - fetchedManifests.set(tag, manifest); + steps.push({ fromVersion: previousVersion, toVersion }); + previousVersion = toVersion; + if (index === manifests.length - 1) { + expectedSha256 = getPatchTargetSha256(manifest, binaryName) ?? ""; } } - - return fetchedManifests; -} - -/** - * Resolve a chain of nightly patches from current to target version. - * - * Uses a lazy approach that only fetches manifests actually needed: - * 1. Single API call: list all `patch-*` tags (cheap — just names) - * 2. Filter to tags in the upgrade range and sort by version - * 3. Fetch manifests for chain tags (typically 1-2 HTTP calls) - * 4. Validate chain linkage and size threshold - * 5. Parallel: download all patch layer blobs concurrently - * - * @param opts.token - GHCR anonymous bearer token - * @param opts.currentVersion - Currently installed nightly version - * @param opts.targetVersion - Target nightly version - * @param opts.fullGzSize - Size of the full .gz layer for threshold calculation - * @param opts.preloadedTags - Pre-fetched patch tags from `listTags`. When provided, - * skips the `listTags` call. Used by `resolveNightlyDelta` to run the tag - * listing in parallel with the target manifest fetch. - * @returns Resolved patch chain, or null if unavailable - */ -export async function resolveNightlyChain(opts: { - token: string; - currentVersion: string; - targetVersion: string; - fullGzSize: number; - preloadedTags?: string[]; - signal?: AbortSignal; -}): Promise { - const { - token, - currentVersion, - targetVersion, - fullGzSize, - preloadedTags, - signal, - } = opts; - const binaryName = getPlatformBinaryName(); - const patchLayerName = `${binaryName}.patch`; - - // Step 1: Use pre-fetched tags or fetch them (for backward compat / direct callers) - const allTags = - preloadedTags ?? (await listTags(token, PATCH_TAG_PREFIX, signal)); - - // Step 2: Extract versions, filter to chain range, sort chronologically - const chainTags = filterAndSortChainTags( - allTags, - currentVersion, - targetVersion - ); - if (chainTags.length === 0 || chainTags.length > MAX_NIGHTLY_CHAIN_DEPTH) { - log.debug( - chainTags.length === 0 - ? "No patch tags found in version range" - : `Nightly chain depth ${chainTags.length} exceeds limit ${MAX_NIGHTLY_CHAIN_DEPTH}` + if (previousVersion !== opts.targetVersion || !expectedSha256) { + Sentry.getActiveSpan()?.setAttribute( + "telemetry_reason", + "version-mismatch" ); return null; } - // Step 3: Fetch manifests for chain tags - const fetchedManifests = await fetchChainManifests({ - token, - tags: chainTags, - allTagCount: allTags.length, - chainTagCount: chainTags.length, - signal, - }); - - // Build ordered manifests — any missing manifest means chain is broken - const manifests: (OciManifest | undefined)[] = chainTags.map((tag) => - fetchedManifests.get(tag) - ); - if (manifests.some((m) => !m)) { - return null; - } - - // Step 4: Validate chain and collect patch info - const validation = validateNightlyChain({ - manifests: manifests as OciManifest[], - chainTags, - currentVersion, - targetVersion, - patchLayerName, - binaryName, - fullGzSize, - }); - if (!validation) { - return null; - } - - // Step 5: Parallel blob download - const downloadResults = await withTracing( - "download-patches", - "http.client", - () => - Promise.all( - validation.digests.map((digest) => - downloadLayerBlob(token, digest, signal).then( - (buf) => new Uint8Array(buf) - ) - ) - ) + const patches = await Promise.all( + digests.map(async (digest) => { + const data = new Uint8Array( + await client.downloadBlobBuffer(opts.token, digest, opts.signal) + ); + return { data, size: data.byteLength }; + }) ); - - const patches: PatchLink[] = []; - let downloadedSize = 0; - for (const data of downloadResults) { - patches.push({ data, size: data.byteLength }); - downloadedSize += data.byteLength; - } - - // Build version steps from chain tags (oldest first) - const steps: { fromVersion: string; toVersion: string }[] = []; - let prevVersion = currentVersion; - for (const tag of chainTags) { - const toVersion = tag.slice(PATCH_TAG_PREFIX.length); - steps.push({ fromVersion: prevVersion, toVersion }); - prevVersion = toVersion; - } - return { patches, - totalSize: downloadedSize, - expectedSha256: validation.expectedSha256, + totalSize: patches.reduce((sum, patch) => sum + patch.size, 0), + expectedSha256, steps, }; } -/** - * Attempt to download and apply delta patches instead of a full binary. - * - * This is the main entry point called by `downloadBinaryToTemp()` in - * upgrade.ts. It discovers available patches, resolves a chain, downloads - * the patches, applies them sequentially, and verifies the result. - * - * @param targetVersion - Version to upgrade to - * @param oldBinaryPath - Path to the currently running binary (used as patch base) - * @param destPath - Path to write the patched binary - * @returns Delta result with SHA-256 and size info, or null if delta is unavailable - */ -// biome-ignore lint/nursery/useMaxParams: established 4-param shape; setMessage is a defaulted spinner-progress extension -export function attemptDeltaUpgrade( - targetVersion: string, +export function applyPatchChain( + chain: PatchChain, oldBinaryPath: string, destPath: string, - offline?: boolean, - setMessage?: SetMessage -): Promise { - if (!canAttemptDelta(targetVersion)) { - return Promise.resolve(null); - } - - const channel = isNightlyVersion(targetVersion) ? "nightly" : "stable"; - + onBytes?: (bytes: number) => void +): Promise { return withTracingSpan( - "delta-upgrade", - "upgrade.delta", + "apply-patches", + "upgrade.delta.apply", async (span) => { - span.setAttribute("delta.from_version", CLI_VERSION); - span.setAttribute("delta.to_version", targetVersion); - - log.debug( - `Attempting delta upgrade from ${CLI_VERSION} to ${targetVersion}` + span.setAttribute("patches.count", chain.patches.length); + span.setAttribute("patches.total_bytes", chain.totalSize); + const sha256 = await applyPatchChainInMemory( + oldBinaryPath, + chain.patches.map((patch) => patch.data), + destPath, + onBytes ); - - try { - const result = - channel === "nightly" - ? await resolveNightlyDelta( - targetVersion, - oldBinaryPath, - destPath, - offline, - setMessage - ) - : await resolveStableDelta( - targetVersion, - oldBinaryPath, - destPath, - offline, - setMessage - ); - - if (result) { - span.setAttribute("delta.patch_bytes", result.patchBytes); - span.setAttribute("delta.chain_length", result.chainLength); - span.setAttribute("delta.sha256", result.sha256.slice(0, 12)); - span.setStatus({ code: 1 }); // OK - - Sentry.metrics.distribution( - "upgrade.delta.patch_bytes", - result.patchBytes, - { - attributes: { channel }, - } - ); - Sentry.metrics.distribution( - "upgrade.delta.chain_length", - result.chainLength, - { - attributes: { channel }, - } - ); - } else { - // No patch available — not an error, just unavailable - span.setAttribute("delta.result", "unavailable"); - span.setStatus({ code: 1 }); // OK — graceful fallback - } - return result; - } catch (error) { - // Record the error in Sentry so we can see delta failures in telemetry. - // Marked non-fatal: the upgrade continues via full download. - Sentry.captureException(error, { - level: "warning", - tags: { - "delta.from_version": CLI_VERSION, - "delta.to_version": targetVersion, - "delta.channel": channel, - }, - contexts: { - delta_upgrade: { - from_version: CLI_VERSION, - to_version: targetVersion, - channel, - old_binary_path: oldBinaryPath, - }, - }, - }); - - const msg = error instanceof Error ? error.message : String(error); - log.warn( - `Delta upgrade failed (${msg}), falling back to full download` + if (sha256 !== chain.expectedSha256) { + throw new Error( + `SHA-256 mismatch after patching: got ${sha256}, expected ${chain.expectedSha256}` ); - span.setStatus({ code: 2 }); // Error - span.setAttribute("delta.result", "error"); - span.setAttribute("delta.error", msg); - return null; } - }, - { "delta.channel": channel } + return sha256; + } ); } -/** - * Build a cache key for Sentry Cache Insights instrumentation. - * Format: `patch-chain:{from}-{to}` (e.g., `patch-chain:0.13.0-0.14.0`). - */ -function patchCacheKey(fromVersion: string, toVersion: string): string { - return `patch-chain:${fromVersion}-${toVersion}`; -} - -/** - * Try to load a cached patch chain, catching and suppressing errors. - * - * Emits a `cache.get` span with standard Sentry Cache Module attributes - * so the operation appears in the Cache Insights dashboard. - * - * @returns Cached chain data, or null if unavailable or on any error - */ -async function tryLoadCachedChain( - currentVersion: string, - targetVersion: string -): Promise { - const key = patchCacheKey(currentVersion, targetVersion); - try { - return await withTracingSpan(key, "cache.get", async (span) => { - span.setAttribute("cache.key", [key]); - const result = await loadCachedChain(currentVersion, targetVersion); - const hit = result !== null; - span.setAttribute("cache.hit", hit); - if (hit) { - span.setAttribute("cache.item_size", result.totalSize); +function makeProgressHandler(setMessage?: SetMessage): ProgressHandler { + let progress: ReturnType | undefined; + let phase: string | undefined; + let previousWritten = 0; + return (event) => { + if (event.type === "bytes") { + if (!progress || phase !== event.phase) { + phase = event.phase; + previousWritten = 0; + progress = makeByteProgress( + `${event.phase === "apply" ? "Applying" : "Processing"} patch(es)`, + event.total, + setMessage + ); } - return result; - }); - } catch { - return null; - } -} - -/** - * Apply a cached or network-resolved chain and return the delta result. - */ -async function applyChainAndReturn( - chain: PatchChain, - oldBinaryPath: string, - destPath: string, - setMessage?: SetMessage -): Promise { - // Progress for the apply phase. The `onBytes` callback fires for every - // output byte of every hop — intermediate in-memory hops AND the final disk - // write — so the total must be the SUM of all hops' output sizes (each - // patch's declared `newSize`), not just the final binary size. Using only - // the last patch's size would make a multi-hop bar hit 100% after hop 1 and - // then freeze. Feeds the surrounding spinner via setMessage — cosmetic, a - // render failure never aborts the apply. - let totalBytes: number | null = 0; - try { - for (const p of chain.patches) { - totalBytes += parsePatchHeader(p.data).newSize; + progress.onProgress(event.written - previousWritten); + previousWritten = event.written; + } else if (event.type === "done") { + progress?.done(); } - } catch { - // Header parse is best-effort for the bar; a corrupt header is rejected - // properly downstream. Leave the bar indeterminate in that case. - totalBytes = null; - } - const progress = makeByteProgress( - `Applying ${chain.patches.length} patch(es)`, - totalBytes, - setMessage - ); - - let sha256: string; - try { - sha256 = await withTracing("apply-patch-chain", "upgrade.delta.apply", () => - applyPatchChain(chain, oldBinaryPath, destPath, progress.onProgress) - ); - } finally { - progress.done(); - } + }; +} +function telemetry(): DeltaTelemetry & { _source: { current?: string } } { + // Expose `current` so attemptDeltaUpgrade's catch path can stamp + // `delta.source` on the active span even when apply fails AFTER a chain + // was successfully resolved (the catch previously left the span without + // this attribute, silently downgrading telemetry fidelity). + const captured: { current?: string } = {}; return { - sha256, - patchBytes: chain.totalSize, - chainLength: chain.patches.length, + _source: captured, + onResolved: ({ source, chain }) => { + captured.current = source; + const span = Sentry.getActiveSpan(); + span?.setAttribute("delta.source", source); + log.debug( + `Resolved patch chain from ${source}: ${chain.patches.length} patch(es), ${formatBytes(chain.totalSize)} total` + ); + }, + onOfflineMiss: () => { + captured.current = "offline_miss"; + Sentry.getActiveSpan()?.setAttribute("delta.source", "offline_miss"); + }, + onUnavailable: (reason: DeltaUnavailableReason) => { + Sentry.getActiveSpan()?.setAttribute("telemetry_reason", reason); + }, }; } -/** Options for the shared cache-first resolve + apply logic */ -type ResolveAndApplyOpts = { - targetVersion: string; - oldBinaryPath: string; - destPath: string; - /** Channel-specific chain resolution callback */ - resolveFromNetwork: () => Promise; - /** Channel label for log messages (e.g., "stable", "nightly") */ - channel: string; - /** When true, skip the network fallback — only use cached patches */ - offline?: boolean; - /** Spinner message setter for apply-phase progress (from withProgress) */ - setMessage?: SetMessage; -}; - -/** - * Shared cache-first resolve + apply logic for both stable and nightly channels. - * - * 1. Check the patch cache for a fully offline upgrade - * 2. If no cache hit, resolve a fresh chain from the network - * 3. Apply the chain and return the delta result - */ -async function resolveAndApplyDelta( - opts: ResolveAndApplyOpts -): Promise { - const { +// biome-ignore lint/nursery/useMaxParams: internal adapter mirrors the preserved public call shape +function resolveDelta( + source: SourceStrategy, + targetVersion: string, + oldBinaryPath: string, + destPath: string, + offline?: boolean, + setMessage?: SetMessage +): Promise<{ result: DeltaResult | null; source: string | undefined }> { + const tel = telemetry(); + return resolveAndApply({ + source, + currentVersion: CLI_VERSION, targetVersion, - oldBinaryPath, + oldPath: oldBinaryPath, destPath, - resolveFromNetwork, - channel, + cache: getPatchCache(), offline, - setMessage, - } = opts; - // Check patch cache first — enables fully offline upgrades - const cached = await tryLoadCachedChain(CLI_VERSION, targetVersion); - if (cached) { - Sentry.getActiveSpan()?.setAttribute("delta.source", "cache"); - log.debug( - `Using cached patches: ${cached.patches.length} patch(es), ${formatBytes(cached.totalSize)} total` - ); - return await applyChainAndReturn( - cached, - oldBinaryPath, - destPath, - setMessage - ); - } - - // In offline mode, skip the network resolution entirely — if the cache - // didn't have the patches, there's nothing more we can do. - if (offline) { - Sentry.getActiveSpan()?.setAttribute("delta.source", "offline_miss"); - return null; - } - - Sentry.getActiveSpan()?.setAttribute("delta.source", "network"); - - const chain = await resolveFromNetwork(); - if (chain) { - log.debug( - `Resolved ${channel} chain: ${chain.patches.length} patch(es), ${formatBytes(chain.totalSize)} total` - ); - } - if (!chain) { - return null; - } - - return await applyChainAndReturn(chain, oldBinaryPath, destPath, setMessage); + onProgress: makeProgressHandler(setMessage), + telemetry: tel, + }).then((result) => ({ result, source: tel._source.current })); } -/** - * Resolve and apply stable delta patches. - * - * Checks the patch cache first for fully offline upgrades. - * Falls back to network resolution if the cache is empty. - * - * @returns Delta result with SHA-256 and size info, or null if delta is unavailable - */ -// biome-ignore lint/nursery/useMaxParams: established 4-param shape; setMessage is a defaulted spinner-progress extension +// biome-ignore lint/nursery/useMaxParams: preserve the existing public API export function resolveStableDelta( targetVersion: string, oldBinaryPath: string, @@ -1111,29 +468,17 @@ export function resolveStableDelta( offline?: boolean, setMessage?: SetMessage ): Promise { - return resolveAndApplyDelta({ + return resolveDelta( + stableSource(), targetVersion, oldBinaryPath, destPath, - resolveFromNetwork: () => - withTracing("resolve-stable-chain", "upgrade.delta.resolve", () => - resolveStableChain(CLI_VERSION, targetVersion) - ), - channel: "stable", offline, - setMessage, - }); + setMessage + ).then(({ result }) => result); } -/** - * Resolve and apply nightly delta patches. - * - * Checks the patch cache first for fully offline upgrades. - * Falls back to network resolution if the cache is empty. - * - * @returns Delta result with SHA-256 and size info, or null if delta is unavailable - */ -// biome-ignore lint/nursery/useMaxParams: established 4-param shape; setMessage is a defaulted spinner-progress extension +// biome-ignore lint/nursery/useMaxParams: preserve the existing public API export function resolveNightlyDelta( targetVersion: string, oldBinaryPath: string, @@ -1141,200 +486,119 @@ export function resolveNightlyDelta( offline?: boolean, setMessage?: SetMessage ): Promise { - return resolveAndApplyDelta({ + return resolveDelta( + nightlySource(), targetVersion, oldBinaryPath, destPath, - resolveFromNetwork: () => resolveNightlyChainWithContext(targetVersion), - channel: "nightly", offline, - setMessage, - }); + setMessage + ).then(({ result }) => result); } -/** - * Resolve a nightly chain with full context setup (token, manifest, tags). - * - * Extracted to share between `resolveNightlyDelta` and `prefetchNightlyPatches`. - * Fetches the GHCR token, target manifest, and patch tags in parallel, - * then resolves the patch chain. - */ -async function resolveNightlyChainWithContext( +// biome-ignore lint/nursery/useMaxParams: preserve the existing public API +export function attemptDeltaUpgrade( targetVersion: string, - signal?: AbortSignal -): Promise { - const token = await withTracing("ghcr-token", "http.client", () => - getAnonymousToken(signal) - ); - - const binaryName = getPlatformBinaryName(); - const targetTag = `nightly-${targetVersion}`; - - // Fetch target manifest and list patch tags in parallel — both only need token - const [nightlyManifest, patchTags] = await Promise.all([ - withTracing("fetch-target-manifest", "http.client", () => - fetchManifest(token, targetTag, signal) - ), - withTracing("list-patch-tags", "http.client", () => - listTags(token, PATCH_TAG_PREFIX, signal) - ), - ]); - - const gzLayer = nightlyManifest.layers.find((l) => { - const title = l.annotations?.["org.opencontainers.image.title"]; - return title === `${binaryName}.gz`; - }); - if (!gzLayer) { - return null; - } - - return await withTracing( - "resolve-nightly-chain", - "upgrade.delta.resolve", - () => - resolveNightlyChain({ - token, - currentVersion: CLI_VERSION, - targetVersion, - fullGzSize: gzLayer.size, - preloadedTags: patchTags, - signal, - }) - ); -} - -/** - * Apply a resolved patch chain and verify the result. - * - * Delegates to {@link applyPatchChainInMemory}, which loads the base binary - * once, keeps every intermediate hop in memory (no per-hop disk writes, - * temp-copies, or SHA-256 passes), and streams only the final binary to - * `destPath`. Because reads and writes never target the same path, there is no - * read/write truncation hazard. - * - * Does **not** set executable permissions — the caller - * (`downloadBinaryToTemp`) handles that uniformly for both delta - * and full-download paths. - * - * @param chain - Resolved patch chain with patches and expected hash - * @param oldBinaryPath - Path to the original binary - * @param destPath - Final output path - * @returns SHA-256 hex of the final output - * @throws {Error} When SHA-256 verification fails - */ -export function applyPatchChain( - chain: PatchChain, oldBinaryPath: string, destPath: string, - onBytes?: (bytes: number) => void -): Promise { + offline?: boolean, + setMessage?: SetMessage +): Promise { + if (!canAttemptDelta(targetVersion)) { + return Promise.resolve(null); + } + const channel = isNightlyVersion(targetVersion) ? "nightly" : "stable"; return withTracingSpan( - "apply-patches", - "upgrade.delta.apply", + "upgrade.delta", + "upgrade.delta", async (span) => { - span.setAttribute("patches.count", chain.patches.length); - span.setAttribute( - "patches.total_bytes", - chain.patches.reduce((sum, p) => sum + p.size, 0) - ); - - log.debug( - `Applying ${chain.patches.length} patch(es), expected SHA-256: ${chain.expectedSha256.slice(0, 12)}...` - ); - - const sha256 = await applyPatchChainInMemory( - oldBinaryPath, - chain.patches.map((p) => p.data), - destPath, - onBytes - ); - - // Verify the final SHA-256 matches - if (sha256 !== chain.expectedSha256) { - throw new Error( - `SHA-256 mismatch after patching: got ${sha256}, expected ${chain.expectedSha256}` + span.setAttribute("delta.from_version", CLI_VERSION); + span.setAttribute("delta.to_version", targetVersion); + let chainSource: string | undefined; + try { + const resolved = await resolveDelta( + channel === "nightly" ? nightlySource() : stableSource(), + targetVersion, + oldBinaryPath, + destPath, + offline, + setMessage + ); + chainSource = resolved.source; + const result = resolved.result; + if (result) { + span.setAttribute("delta.patch_bytes", result.patchBytes); + span.setAttribute("delta.chain_length", result.chainLength); + Sentry.metrics.distribution( + "upgrade.delta.patch_bytes", + result.patchBytes, + { attributes: { channel } } + ); + Sentry.metrics.distribution( + "upgrade.delta.chain_length", + result.chainLength, + { attributes: { channel } } + ); + } else { + span.setAttribute("delta.result", "unavailable"); + } + span.setStatus({ code: 1 }); + return result; + } catch (error) { + Sentry.captureException(error, { + level: "warning", + tags: { + "delta.from_version": CLI_VERSION, + "delta.to_version": targetVersion, + "delta.channel": channel, + }, + }); + // If the chain was resolved but apply threw, the source was captured + // by telemetry().onResolved — stamp it on the span so error spans + // don't silently lose the network/cache/offline_miss attribution. + const errorSpan = Sentry.getActiveSpan(); + if (chainSource !== undefined && errorSpan) { + errorSpan.setAttribute("delta.source", chainSource); + } + const message = error instanceof Error ? error.message : String(error); + log.warn( + `Delta upgrade failed (${message}), falling back to full download` ); + span.setStatus({ code: 2 }); + span.setAttribute("delta.result", "error"); + span.setAttribute("delta.error", message); + return null; } - - return sha256; - } + }, + { "delta.channel": channel } ); } -// =================================================================== -// Patch Pre-fetching (called from version-check.ts) -// =================================================================== - -/** - * Resolve a chain and save it to the cache for offline upgrades. - * - * Shared by both nightly and stable prefetch paths. Checks abort signal - * at key checkpoints to bail early when the process is exiting. - * - * @param targetVersion - The newly discovered version - * @param signal - Abort signal (process may exit) - * @param resolveChain - Channel-specific chain resolution callback - */ -async function prefetchAndCache( +async function prefetch( + source: SourceStrategy, targetVersion: string, - signal: AbortSignal | undefined, - resolveChain: () => Promise + signal?: AbortSignal ): Promise { if (!canAttemptDelta(targetVersion) || signal?.aborted) { return; } - - const chain = await resolveChain(); + const chain = await source.resolveChain(CLI_VERSION, targetVersion, signal); if (!chain?.steps || signal?.aborted) { return; } - - const key = patchCacheKey(CLI_VERSION, targetVersion); - const steps = chain.steps; - await withTracingSpan(key, "cache.put", async (span) => { - span.setAttribute("cache.key", [key]); - span.setAttribute("cache.item_size", chain.totalSize); - await savePatchesToCache(chain, steps); - }); + await getPatchCache().save(chain, chain.steps); } -/** - * Pre-fetch nightly delta patches for a future upgrade. - * - * Called during background version check after discovering a new version. - * Downloads the patch chain and caches it to disk so that the subsequent - * `sentry cli upgrade` can apply patches offline. - * - * Runs as fire-and-forget — errors are silently ignored since this is - * a best-effort optimization. - * - * @param targetVersion - The newly discovered nightly version - * @param signal - Abort signal (process may exit) - */ export function prefetchNightlyPatches( targetVersion: string, signal?: AbortSignal ): Promise { - return prefetchAndCache(targetVersion, signal, () => - resolveNightlyChainWithContext(targetVersion, signal) - ); + return prefetch(nightlySource(), targetVersion, signal); } -/** - * Pre-fetch stable delta patches for a future upgrade. - * - * Called during background version check after discovering a new stable version. - * Downloads the patch chain and caches it to disk so that the subsequent - * `sentry cli upgrade` can apply patches offline. - * - * @param targetVersion - The newly discovered stable version - * @param signal - Abort signal (process may exit) - */ export function prefetchStablePatches( targetVersion: string, signal?: AbortSignal ): Promise { - return prefetchAndCache(targetVersion, signal, () => - resolveStableChain(CLI_VERSION, targetVersion, signal) - ); + return prefetch(stableSource(), targetVersion, signal); } diff --git a/src/lib/patch-cache.ts b/src/lib/patch-cache.ts index 92b3ede821..ca10d76a33 100644 --- a/src/lib/patch-cache.ts +++ b/src/lib/patch-cache.ts @@ -1,430 +1,43 @@ -/** - * Patch Cache - * - * File-based cache for delta upgrade patches. Patches are downloaded - * during background version checks so that `sentry cli upgrade` can - * apply them offline without any network calls. - * - * Cache location: /patch-cache/ - * - -.patch — raw binary patch data - * - chain--.json — chain metadata - * - * Uses file-based storage (not SQLite) to avoid bloating the DB with - * 50-80KB binary blobs. The cache is channel-agnostic — the same - * version-based naming works for both nightly (GHCR) and stable - * (GitHub Releases) channels. - * - * Patches accumulate across version check runs: if a user skips 3 - * checks, all 3 patches are cached and a multi-hop upgrade is fully - * offline. - */ - -import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { makeCache, type PatchCache, type PatchChain } from "binpatch"; import { getConfigDir } from "./db/index.js"; -const PATCH_CACHE_DIR = "patch-cache"; - -/** 7-day TTL for cached patches (milliseconds) */ -const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; - -/** Maximum number of chain steps to prevent infinite loops from corrupt metadata */ -const MAX_CHAIN_WALK_DEPTH = 10; - -/** Metadata for a single patch step */ -export type PatchStepMeta = { - fromVersion: string; - toVersion: string; - size: number; -}; - -/** Chain metadata stored alongside patch files */ -export type ChainMeta = { - fromVersion: string; - toVersion: string; - expectedSha256: string; - cachedAt: number; - patches: PatchStepMeta[]; -}; - -function getCacheDir(): string { - return join(getConfigDir(), PATCH_CACHE_DIR); -} - -async function ensureCacheDir(): Promise { - await mkdir(getCacheDir(), { recursive: true, mode: 0o700 }); -} - -/** - * Sanitize version strings for safe filenames. - * Replaces any character that isn't alphanumeric, dot, or hyphen with underscore. - */ -function sanitizeVersion(version: string): string { - return version.replace(/[^a-zA-Z0-9.-]/g, "_"); -} - -/** Build the filename for a patch file given a from→to version pair. */ -export function patchFileName(fromVersion: string, toVersion: string): string { - return `${sanitizeVersion(fromVersion)}-${sanitizeVersion(toVersion)}.patch`; -} - -/** Build the filename for a chain metadata file. */ -export function chainFileName(fromVersion: string, toVersion: string): string { - return `chain-${sanitizeVersion(fromVersion)}-${sanitizeVersion(toVersion)}.json`; -} +export type { ChainMeta, PatchStepMeta } from "binpatch"; +// biome-ignore lint/performance/noBarrelFile: preserve the existing cache API +export { chainFileName, patchFileName } from "binpatch"; -/** - * Check whether an error is a "file not found" error (ENOENT). - * Used to avoid TOCTOU races from `exists()` + `read()`. - */ -function isNotFound(err: unknown): boolean { - return err instanceof Error && "code" in err && err.code === "ENOENT"; +function cache(): PatchCache { + return makeCache(join(getConfigDir(), "patch-cache")); } -/** - * Save a patch file and its chain metadata to the cache. - * - * Each patch in the chain is stored individually (keyed by from-to version), - * plus one chain metadata file per resolved chain. This allows patches from - * different version check runs to accumulate and form complete chains. - * - * @param chain - The resolved patch chain (patches with data and expected hash) - * @param steps - Version pairs for each patch (ordered, oldest first) - */ -export async function savePatchesToCache( - chain: { - patches: { data: Uint8Array; size: number }[]; - expectedSha256: string; - }, +export function savePatchesToCache( + chain: Pick, steps: { fromVersion: string; toVersion: string }[] ): Promise { - await ensureCacheDir(); - const cacheDir = getCacheDir(); - - // Save all patch files in parallel - await Promise.all( - chain.patches.flatMap((patch, i) => { - const step = steps[i]; - if (!(step && patch)) { - return []; - } - const filePath = join( - cacheDir, - patchFileName(step.fromVersion, step.toVersion) - ); - return [writeFile(filePath, patch.data)]; - }) - ); - - // Save chain metadata - if (steps.length > 0) { - const firstStep = steps.at(0); - const lastStep = steps.at(-1); - if (firstStep && lastStep) { - const meta: ChainMeta = { - fromVersion: firstStep.fromVersion, - toVersion: lastStep.toVersion, - expectedSha256: chain.expectedSha256, - cachedAt: Date.now(), - patches: steps.map((s, i) => ({ - fromVersion: s.fromVersion, - toVersion: s.toVersion, - size: chain.patches[i]?.size ?? 0, - })), - }; - const metaPath = join( - cacheDir, - chainFileName(firstStep.fromVersion, lastStep.toVersion) - ); - await writeFile(metaPath, JSON.stringify(meta), "utf-8"); - } - } -} - -/** - * Load all chain metadata files from the cache directory. - * - * @returns Array of parsed chain metadata - */ -async function loadAllChainMetas(cacheDir: string): Promise { - let files: string[]; - try { - files = await readdir(cacheDir); - } catch (err) { - if (isNotFound(err)) { - return []; - } - throw err; - } - - const metaFiles = files.filter( - (f) => f.startsWith("chain-") && f.endsWith(".json") - ); - - const results = await Promise.all( - metaFiles.map(async (file) => { - try { - return JSON.parse( - await readFile(join(cacheDir, file), "utf-8") - ) as ChainMeta; - } catch { - // Corrupt metadata file — skip - return null; - } - }) - ); - - return results.filter((m): m is ChainMeta => m !== null); + return cache().save(chain, steps); } -/** - * Build a step map from chain metadata: fromVersion → { toVersion, size }. - * Allows stitching chains from different version check runs. - */ -function buildStepMap( - chainMetas: ChainMeta[] -): Map { - const stepMap = new Map(); - for (const meta of chainMetas) { - for (const step of meta.patches) { - stepMap.set(step.fromVersion, { - toVersion: step.toVersion, - size: step.size, - }); - } - } - return stepMap; -} - -/** - * Walk from currentVersion toward targetVersion using the step map. - * - * @returns Ordered list of version steps, or null if chain is incomplete - */ -function walkChainSteps( - stepMap: Map, - currentVersion: string, - targetVersion: string -): { fromVersion: string; toVersion: string }[] | null { - const steps: { fromVersion: string; toVersion: string }[] = []; - let version = currentVersion; - while (version !== targetVersion) { - const next = stepMap.get(version); - if (!next) { - return null; - } - steps.push({ fromVersion: version, toVersion: next.toVersion }); - version = next.toVersion; - - // Safety: prevent infinite loops from corrupt metadata - if (steps.length > MAX_CHAIN_WALK_DEPTH) { - return null; - } - } - return steps.length > 0 ? steps : null; -} - -/** - * Try to load a complete patch chain from the cache. - * - * Walks from currentVersion toward targetVersion, loading individual - * patch files. Each patch is keyed by `fromVersion-toVersion`, so patches - * from different version check runs can be stitched together. - * - * Requires chain metadata files to know the expected SHA-256 and - * the intermediate version steps. - * - * @returns Cached chain data if all patches are available, null otherwise - */ export async function loadCachedChain( currentVersion: string, targetVersion: string -): Promise<{ - patches: { data: Uint8Array; size: number }[]; - totalSize: number; - expectedSha256: string; -} | null> { - const cacheDir = getCacheDir(); - - const chainMetas = await loadAllChainMetas(cacheDir); - if (chainMetas.length === 0) { - return null; - } - - const stepMap = buildStepMap(chainMetas); - const steps = walkChainSteps(stepMap, currentVersion, targetVersion); - if (!steps) { +): ReturnType { + const result = await cache().load(currentVersion, targetVersion); + if (!result) { return null; } - - // Find the expectedSha256 from metadata that covers the target version - let expectedSha256 = ""; - for (const meta of chainMetas) { - if (meta.toVersion === targetVersion && meta.expectedSha256) { - expectedSha256 = meta.expectedSha256; - break; - } - } - if (!expectedSha256) { - return null; - } - - // Load all patch files in parallel, bail on any missing file - const loadResults = await Promise.all( - steps.map(async (step) => { - const filePath = join( - cacheDir, - patchFileName(step.fromVersion, step.toVersion) - ); - try { - const data = new Uint8Array(await readFile(filePath)); - return { data, size: data.byteLength }; - } catch (err) { - if (isNotFound(err)) { - return null; - } - throw err; - } - }) - ); - - // Any missing patch file means an incomplete chain - const patches: { data: Uint8Array; size: number }[] = []; - let totalSize = 0; - for (const result of loadResults) { - if (!result) { - return null; - } - patches.push(result); - totalSize += result.size; - } - - return { patches, totalSize, expectedSha256 }; -} - -/** - * Remove expired chain entries and their exclusive patch files. - * - * Preserves patch files still referenced by live (non-expired) chains. - * Fully async and fire-and-forget — errors on individual file deletions - * are silently ignored. - */ -async function removeExpiredEntries( - cacheDir: string, - files: string[], - now: number -): Promise { - const expiredMetas: ChainMeta[] = []; - const livePatchFiles = new Set(); - - // Classify chains into expired vs live - const metaResults = await Promise.all( - files - .filter((f) => f.startsWith("chain-") && f.endsWith(".json")) - .map(async (file) => { - try { - const meta = JSON.parse( - await readFile(join(cacheDir, file), "utf-8") - ) as ChainMeta; - return { file, meta }; - } catch { - // Corrupt metadata — schedule for removal - await unlink(join(cacheDir, file)).catch(() => { - /* best-effort */ - }); - return null; - } - }) - ); - - for (const result of metaResults) { - if (!result) { - continue; - } - if (now - result.meta.cachedAt > CACHE_MAX_AGE_MS) { - expiredMetas.push(result.meta); - } else { - for (const step of result.meta.patches) { - livePatchFiles.add(patchFileName(step.fromVersion, step.toVersion)); - } - } - } - - // Delete expired entries, preserving patch files used by live chains - const deletions: Promise[] = []; - for (const meta of expiredMetas) { - for (const step of meta.patches) { - const name = patchFileName(step.fromVersion, step.toVersion); - if (!livePatchFiles.has(name)) { - deletions.push( - unlink(join(cacheDir, name)).catch(() => { - /* best-effort */ - }) - ); - } - } - deletions.push( - unlink( - join(cacheDir, chainFileName(meta.fromVersion, meta.toVersion)) - ).catch(() => { - /* best-effort */ - }) - ); - } - - await Promise.all(deletions); + return { + ...result, + patches: result.patches.map((patch) => ({ + ...patch, + data: new Uint8Array(patch.data), + })), + }; } -/** - * Remove stale cache entries older than 7 days. - * Called opportunistically during version checks. - * - * Uses a two-pass approach: first identifies expired vs live chains, - * then deletes expired entries while preserving patch files that are - * still referenced by non-expired chains (prevents breaking multi-hop - * chains that share patch files with expired shorter chains). - * - * Cleanup runs as fire-and-forget — the returned promise is not awaited - * by callers and errors are silently suppressed. - */ -export async function cleanupPatchCache(): Promise { - const cacheDir = getCacheDir(); - let files: string[]; - try { - files = await readdir(cacheDir); - } catch (err) { - if (isNotFound(err)) { - return; - } - throw err; - } - await removeExpiredEntries(cacheDir, files, Date.now()); +export function cleanupPatchCache(): Promise { + return cache().cleanup(); } -/** - * Remove all cached patch files and chain metadata. - * - * Called after a successful upgrade — cached patches for the old version - * are no longer useful since the binary has already been updated. - * Best-effort: errors on individual file deletions are silently ignored. - */ -export async function clearPatchCache(): Promise { - const cacheDir = getCacheDir(); - let files: string[]; - try { - files = await readdir(cacheDir); - } catch (err) { - if (isNotFound(err)) { - return; - } - throw err; - } - - await Promise.all( - files.map((file) => - unlink(join(cacheDir, file)).catch(() => { - /* best-effort */ - }) - ) - ); +export function clearPatchCache(): Promise { + return cache().clear(); } diff --git a/test/e2e/delta-upgrade.test.ts b/test/e2e/delta-upgrade.test.ts index a0e9af4b85..b5e409b702 100644 --- a/test/e2e/delta-upgrade.test.ts +++ b/test/e2e/delta-upgrade.test.ts @@ -15,9 +15,9 @@ import { existsSync, mkdtempSync, unlinkSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { applyPatch, applyPatchChainInMemory } from "binpatch"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { getPlatformBinaryName } from "../../src/lib/binary.js"; -import { applyPatch, applyPatchChainInMemory } from "../../src/lib/bspatch.js"; // Restore real fetch for E2E tests (preload.ts mocks it globally) const realFetch = (globalThis as { __originalFetch?: typeof fetch }) diff --git a/test/lib/bspatch.property.test.ts b/test/lib/bspatch.property.test.ts index 3b87074442..f6f7fd636a 100644 --- a/test/lib/bspatch.property.test.ts +++ b/test/lib/bspatch.property.test.ts @@ -5,9 +5,9 @@ * functions across random inputs. */ +import { offtin, parsePatchHeader } from "binpatch"; import { assert as fcAssert, integer, property, uint8Array } from "fast-check"; import { describe, expect, test } from "vitest"; -import { offtin, parsePatchHeader } from "../../src/lib/bspatch.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; describe("property: offtin", () => { diff --git a/test/lib/bspatch.test.ts b/test/lib/bspatch.test.ts index 3b035b5b5e..d474b86557 100644 --- a/test/lib/bspatch.test.ts +++ b/test/lib/bspatch.test.ts @@ -16,7 +16,6 @@ import { readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { zstdCompressSync } from "node:zlib"; -import { describe, expect, test } from "vitest"; import { addDiffChunk, applyPatch, @@ -24,7 +23,8 @@ import { applyPatchToMemory, MAX_OUTPUT_SIZE, parsePatchHeader, -} from "../../src/lib/bspatch.js"; +} from "binpatch"; +import { describe, expect, test } from "vitest"; const FIXTURES_DIR = join(import.meta.dirname, "../fixtures/patches"); diff --git a/test/lib/delta-upgrade.mocked.test.ts b/test/lib/delta-upgrade.mocked.test.ts index 663b88941e..cd0a2fddc2 100644 --- a/test/lib/delta-upgrade.mocked.test.ts +++ b/test/lib/delta-upgrade.mocked.test.ts @@ -17,6 +17,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("delta-upgrade-mocked-"); + // ============================================================================ // Mock Setup // ============================================================================ diff --git a/test/lib/delta-upgrade.test.ts b/test/lib/delta-upgrade.test.ts index bd839dc2e4..3752d4ae67 100644 --- a/test/lib/delta-upgrade.test.ts +++ b/test/lib/delta-upgrade.test.ts @@ -1719,12 +1719,11 @@ describe("resolveNightlyDelta", () => { } test("returns null when GHCR token fetch fails", async () => { - // Mock token endpoint to fail → resolveNightlyDelta throws → caught by caller mockFetch(async () => new Response("Unauthorized", { status: 401 })); await expect( resolveNightlyDelta("0.14.0-dev.123", "/tmp/fake-old", "/tmp/fake-out") - ).rejects.toThrow(); + ).resolves.toBeNull(); }); test("returns null when no patch tags exist for the version range", async () => {