From 5892cd1ea490cb0b40aa5651de3e15576f7a38c7 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 15:41:51 +0000 Subject: [PATCH 1/8] fix(read): avoided libarchive for tar reads - Parsed tar and tar.gz members in-process with bounded gzip inflation. - Added UTF-8 ustar-prefix coverage for the minimal libarchive crash shape. Fixes #4774 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/utils/zip.ts | 254 ++++++++++++++++++++--- packages/coding-agent/test/tools.test.ts | 29 +++ 3 files changed, 260 insertions(+), 27 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 459ec14a8..feacbc7e6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed valid `.tar` and `.tar.gz` archive reads terminating omp through libarchive by parsing tar members in-process ([#4774](https://github.com/can1357/oh-my-pi/issues/4774)). + ## [16.3.11] - 2026-07-06 ### Changed diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index 962c4e18c..521a78538 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -1,10 +1,13 @@ // The single archive boundary for the codebase: ZIP (framed here, over the raw -// DEFLATE codec in `node:zlib`) and tar / tar.gz (via `Bun.Archive`). This is -// the ONLY module that frames ZIP containers or touches `Bun.Archive`; the +// DEFLATE codec in `node:zlib`) and tar / tar.gz (parsed in-process here, gzip +// via `node:zlib`; only archive *writing* uses `Bun.Archive`). This is the ONLY +// module that frames ZIP containers, parses tar, or touches `Bun.Archive`; the // markit document converters, the read/search/write tools, the URL fetcher, the // debug report bundler, and the tool-binary installer all go through here so // there is exactly one archive implementation to reason about. Do not parse or -// build ZIP/tar, or call `Bun.Archive`, anywhere else. +// build ZIP/tar, or call `Bun.Archive`, anywhere else. Tar *reads* deliberately +// avoid libarchive: its internal allocation-failure path aborts the whole +// process (#4774). import * as path from "node:path"; import * as zlib from "node:zlib"; import { formatBytes } from "@oh-my-pi/pi-utils"; @@ -144,7 +147,9 @@ function memoryByteSource(buffer: Uint8Array): ByteSource { interface TarStorage { type: "tar"; - file: File; + buffer: Uint8Array; + dataOffset: number; + sparse: boolean; } interface ZipStorage { @@ -589,38 +594,233 @@ async function readZipFileBytes(storage: ZipStorage, uncompressedSize: number): return decodeZipMember(compressedBytes, storage.compression, uncompressedSize); } -async function readTarEntries(bytes: Uint8Array): Promise { - let archive: Bun.Archive; - try { - archive = new Bun.Archive(bytes); - } catch (error) { - throw new ToolError(error instanceof Error ? error.message : String(error)); - } +const TAR_BLOCK_SIZE = 512; +const TAR_NAME_OFFSET = 0; +const TAR_NAME_LENGTH = 100; +const TAR_SIZE_OFFSET = 124; +const TAR_SIZE_LENGTH = 12; +const TAR_MTIME_OFFSET = 136; +const TAR_MTIME_LENGTH = 12; +const TAR_CHECKSUM_OFFSET = 148; +const TAR_CHECKSUM_LENGTH = 8; +const TAR_TYPEFLAG_OFFSET = 156; +const TAR_PREFIX_OFFSET = 345; +const TAR_PREFIX_LENGTH = 155; +const GZIP_MAGIC_0 = 0x1f; +const GZIP_MAGIC_1 = 0x8b; +const TAR_TEXT_DECODER = new TextDecoder(); - let files: Map; +/** + * Decompress a gzip stream in-process, bounded to the tar archive cap so a + * gzip bomb cannot inflate without limit. Non-gzip input passes through. + */ +function gunzipIfNeeded(bytes: Uint8Array): Uint8Array { + if (bytes.length >= 2 && bytes[0] === GZIP_MAGIC_0 && bytes[1] === GZIP_MAGIC_1) { + return new Uint8Array(zlib.gunzipSync(bytes, { maxOutputLength: MAX_TAR_ARCHIVE_BYTES })); + } + return bytes; +} + +/** Read a NUL-terminated tar header string, clamped to the buffer bounds. */ +function readTarString(buffer: Uint8Array, offset: number, length: number): string { + const limit = Math.min(offset + length, buffer.length); + let end = offset; + while (end < limit && buffer[end] !== 0) end++; + return TAR_TEXT_DECODER.decode(buffer.subarray(offset, end)); +} + +/** + * Read a tar numeric header field: GNU base-256 (high bit set) or the usual + * NUL/space-padded octal. Non-octal bytes are ignored so trailing padding does + * not corrupt the value. + */ +function readTarNumeric(buffer: Uint8Array, offset: number, length: number): number { + if (offset >= buffer.length) return 0; + if ((buffer[offset]! & 0x80) !== 0) { + let value = buffer[offset]! & 0x7f; + for (let i = 1; i < length; i++) value = value * 256 + (buffer[offset + i] ?? 0); + return value; + } + let value = 0; + for (let i = 0; i < length; i++) { + const c = buffer[offset + i]; + if (c !== undefined && c >= 0x30 && c <= 0x37) value = value * 8 + (c - 0x30); + } + return value; +} + +function isTarZeroBlock(buffer: Uint8Array, offset: number): boolean { + for (let i = 0; i < TAR_BLOCK_SIZE; i++) { + if (buffer[offset + i] !== 0) return false; + } + return true; +} + +/** Verify a tar header block's checksum (both unsigned and signed conventions). */ +function tarChecksumMatches(buffer: Uint8Array, offset: number): boolean { + const stored = readTarNumeric(buffer, offset + TAR_CHECKSUM_OFFSET, TAR_CHECKSUM_LENGTH); + let unsigned = 0; + let signed = 0; + for (let i = 0; i < TAR_BLOCK_SIZE; i++) { + const inChecksum = i >= TAR_CHECKSUM_OFFSET && i < TAR_CHECKSUM_OFFSET + TAR_CHECKSUM_LENGTH; + const byte = inChecksum ? 0x20 : (buffer[offset + i] ?? 0); + unsigned += byte; + signed += (byte << 24) >> 24; + } + return stored === unsigned || stored === signed; +} + +/** Parse a PAX extended-header payload into its `key → value` records. */ +function parsePaxRecords(data: Uint8Array): Map { + const attrs = new Map(); + let pos = 0; + while (pos < data.length) { + let space = pos; + while (space < data.length && data[space] !== 0x20) space++; + if (space >= data.length) break; + let length = 0; + let valid = false; + for (let i = pos; i < space; i++) { + const c = data[i]!; + if (c < 0x30 || c > 0x39) { + valid = false; + break; + } + length = length * 10 + (c - 0x30); + valid = true; + } + if (!valid || length <= 0 || pos + length > data.length) break; + const record = data.subarray(space + 1, pos + length - 1); + const eq = record.indexOf(0x3d); + if (eq >= 0) { + attrs.set(TAR_TEXT_DECODER.decode(record.subarray(0, eq)), TAR_TEXT_DECODER.decode(record.subarray(eq + 1))); + } + pos += length; + } + return attrs; +} + +function paxDeclaresSparse(pax: Map | undefined): boolean { + if (!pax) return false; + for (const key of pax.keys()) { + if (key.startsWith("GNU.sparse.")) return true; + } + return false; +} + +/** + * Index a tar (optionally gzip-compressed) archive entirely in TypeScript. + * Handles ustar/GNU/pax layouts, `./`-prefixed and `prefix`-split names, GNU + * `@LongLink` long names, and pax `path`/`size` overrides. This deliberately + * avoids `Bun.Archive`/libarchive, whose internal allocation-failure path calls + * `abort()` and takes down the whole process on a crafted or oversized member + * (#4774). Sparse members are indexed but flagged; reading their bytes throws a + * catchable `ToolError` rather than returning a misassembled payload. + */ +function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { + let buffer: Uint8Array; try { - files = await archive.files(); + buffer = gunzipIfNeeded(rawBytes); } catch (error) { throw new ToolError(error instanceof Error ? error.message : String(error)); } const entries: ArchiveIndexEntry[] = []; - for (const [rawPath, file] of files) { - const normalizedPath = normalizeArchiveEntryPath(rawPath); + let offset = 0; + let longName: string | undefined; + let pax: Map | undefined; + + while (offset + TAR_BLOCK_SIZE <= buffer.length) { + if (isTarZeroBlock(buffer, offset)) break; + if (!tarChecksumMatches(buffer, offset)) { + throw new ToolError("Invalid or corrupt tar archive header"); + } + + const typeFlag = String.fromCharCode(buffer[offset + TAR_TYPEFLAG_OFFSET] || 0x30); + let size = readTarNumeric(buffer, offset + TAR_SIZE_OFFSET, TAR_SIZE_LENGTH); + let name = readTarString(buffer, offset + TAR_NAME_OFFSET, TAR_NAME_LENGTH); + const prefix = readTarString(buffer, offset + TAR_PREFIX_OFFSET, TAR_PREFIX_LENGTH); + if (prefix) name = `${prefix}/${name}`; + const mtime = readTarNumeric(buffer, offset + TAR_MTIME_OFFSET, TAR_MTIME_LENGTH); + + offset += TAR_BLOCK_SIZE; + const dataBlocks = Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + + // Metadata-only headers: consume their payload, remember it for the next + // file header, then continue. + if (typeFlag === "L") { + longName = readTarString(buffer, offset, size); + offset += dataBlocks; + continue; + } + if (typeFlag === "K") { + offset += dataBlocks; + continue; + } + if (typeFlag === "x" || typeFlag === "X") { + pax = parsePaxRecords(buffer.subarray(offset, Math.min(offset + size, buffer.length))); + offset += dataBlocks; + continue; + } + if (typeFlag === "g") { + offset += dataBlocks; + continue; + } + + if (longName !== undefined) name = longName; + const paxPath = pax?.get("path"); + if (paxPath !== undefined) name = paxPath; + const paxSize = pax?.get("size"); + if (paxSize !== undefined) { + const parsed = Number.parseInt(paxSize, 10); + if (Number.isFinite(parsed) && parsed >= 0) size = parsed; + } + const sparse = typeFlag === "S" || paxDeclaresSparse(pax); + const dataOffset = offset; + const memberDataBlocks = Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + offset += memberDataBlocks; + longName = undefined; + pax = undefined; + + const isDirectory = typeFlag === "5" || name.endsWith("/"); + const normalizedPath = normalizeArchiveEntryPath(name); if (!normalizedPath) continue; - const mtimeMs = file.lastModified > 0 ? file.lastModified : undefined; + const mtimeMs = mtime > 0 ? mtime * 1000 : undefined; + + if (isDirectory) { + entries.push({ path: normalizedPath, isDirectory: true, size: 0, mtimeMs }); + continue; + } + // Only regular-file typeflags carry inline data we can slice. + if (typeFlag !== "0" && typeFlag !== "\0" && typeFlag !== "7" && typeFlag !== "S") continue; entries.push({ path: normalizedPath, isDirectory: false, - size: file.size, + size, mtimeMs, - storage: { type: "tar", file }, + storage: { type: "tar", buffer, dataOffset, sparse }, }); } return entries; } +/** + * Slice one indexed tar member's bytes out of the archive buffer. Sparse + * members cannot be reassembled from a contiguous slice, so reading them throws + * a catchable error instead of returning corrupt data. + */ +function extractTarMember(storage: TarStorage, size: number, memberPath: string): Uint8Array { + if (storage.sparse) { + throw new ToolError(`Archive member '${memberPath}' is a sparse file and cannot be read`); + } + const end = storage.dataOffset + size; + if (end > storage.buffer.length) { + throw new ToolError(`Archive member '${memberPath}' is truncated`); + } + return storage.buffer.subarray(storage.dataOffset, end); +} + async function readZipEntries(source: ByteSource): Promise { const directoryInfo = await readZipCentralDirectoryInfo(source); const centralDirectory = await source.read(directoryInfo.offset, directoryInfo.offset + directoryInfo.size); @@ -762,7 +962,7 @@ export class ArchiveReader { const bytes = entry.storage.type === "tar" - ? await entry.storage.file.bytes() + ? extractTarMember(entry.storage, entry.size, normalizedPath) : await readZipFileBytes(entry.storage, entry.size); return { @@ -797,7 +997,7 @@ export async function openArchive(source: ArchiveSource): Promise `Archive is too large to read in memory (${formatBytes(archiveSize)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`, ); } - return new ArchiveReader(format, await readTarEntries(await file.bytes())); + return new ArchiveReader(format, readTarEntries(await file.bytes())); } const { bytes, format } = source; @@ -809,7 +1009,7 @@ export async function openArchive(source: ArchiveSource): Promise `Archive is too large to read in memory (${formatBytes(bytes.byteLength)} > ${formatBytes(MAX_TAR_ARCHIVE_BYTES)} limit)`, ); } - return new ArchiveReader(format, await readTarEntries(bytes)); + return new ArchiveReader(format, readTarEntries(bytes)); } /** Render the top-level entries of an in-memory archive as one line each. */ @@ -842,9 +1042,9 @@ async function memberToBytes(content: ArchiveMemberContent): Promise /** * Fully materialize every file member into a `path → content` map: ZIP members - * are inflated in memory, tar members are returned as lazy `File`s. Use this - * when you need every entry (rewrite, extract); for browsing or single-member - * reads prefer `openArchive`, which is lazy for ZIP. + * are inflated in memory, tar members are sliced from the decoded archive + * buffer. Use this when you need every entry (rewrite, extract); for browsing + * or single-member reads prefer `openArchive`, which is lazy for ZIP. */ export async function readArchiveEntries(source: ArchiveSource): Promise> { const { bytes, format } = await resolveArchiveBytes(source); @@ -856,9 +1056,9 @@ export async function readArchiveEntries(source: ArchiveSource): Promise { expect(result.details?.isDirectory).toBe(true); }); + it("should read tar.gz members with UTF-8 ustar prefixes", async () => { + const archivePath = path.join(testDir, "unicode-prefix.tar.gz"); + const prefix = "bun-da3851e57ae130c5594d0e208a5da5ba8c13edfb/test/js/node/test/fixtures/copy/utf/新建文件夹"; + const memberPath = `${prefix}/experimental.json`; + fs.writeFileSync( + archivePath, + zlib.gzipSync( + createTarArchive([ + { + path: "experimental.json", + prefix, + content: '{ "type": "module" }', + }, + ]), + ), + ); + + const rootResult = await readTool.execute("test-call-tar-unicode-prefix-root", { path: archivePath }); + expect(getTextOutput(rootResult)).toContain("bun-da3851e57ae130c5594d0e208a5da5ba8c13edfb/"); + expect(rootResult.details?.isDirectory).toBe(true); + + const memberResult = await readTool.execute("test-call-tar-unicode-prefix-member", { + path: `${archivePath}:${memberPath}`, + }); + expect(getTextOutput(memberResult)).toContain('{ "type": "module" }'); + }); + it("should list archive subdirectories", async () => { const archivePath = path.join(testDir, "fixture.zip"); fs.writeFileSync( From 1588f79ecf88208f529e2898e39f272f828fddf7 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 15:52:01 +0000 Subject: [PATCH 2/8] fix(read): rejected truncated tar members while indexing A member header declaring more bytes than remain in the buffer now throws a ToolError during indexing instead of being listed as a valid entry that only fails on read. Fixes #4774 --- packages/coding-agent/src/utils/zip.ts | 7 +++++++ packages/coding-agent/test/tools.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index 521a78538..014f04f66 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -793,6 +793,13 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { } // Only regular-file typeflags carry inline data we can slice. if (typeFlag !== "0" && typeFlag !== "\0" && typeFlag !== "7" && typeFlag !== "S") continue; + // A declared payload that runs past the buffer means the archive was + // truncated mid-member (e.g. a partial download). Reject it while + // indexing so a root listing does not present a truncated archive as a + // valid directory, with the failure only surfacing on a later member read. + if (dataOffset + memberDataBlocks > buffer.length) { + throw new ToolError(`Archive member '${normalizedPath}' is truncated`); + } entries.push({ path: normalizedPath, isDirectory: false, diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 3c7960792..ec9f46d6a 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -707,6 +707,16 @@ describe("Coding Agent Tools", () => { expect(getTextOutput(memberResult)).toContain('{ "type": "module" }'); }); + it("should reject a truncated tar member while indexing", async () => { + const archivePath = path.join(testDir, "truncated.tar"); + // A full, valid archive declares 2048 bytes for `big.txt`; slicing the + // payload mid-member leaves the header's declared size pointing past EOF. + const complete = createTarArchive([{ path: "big.txt", content: "A".repeat(2048) }]); + fs.writeFileSync(archivePath, complete.subarray(0, 512 + 256)); + + await expect(readTool.execute("test-call-tar-truncated", { path: archivePath })).rejects.toThrow(/truncated/); + }); + it("should list archive subdirectories", async () => { const archivePath = path.join(testDir, "fixture.zip"); fs.writeFileSync( From b49e41fc7a2a6512a644fdd0c4d3eafb80e0478f Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 16:02:00 +0000 Subject: [PATCH 3/8] fix(read): rejected non-tar gzip payloads while indexing A gzip stream whose decompressed payload never presents a complete tar header or terminating zero block (a plain .txt.gz, or a tar truncated before the first header) now raises a catchable ToolError instead of returning an empty index rendered as '(empty archive directory)'. fetch falls back to binary rendering. Fixes #4774 --- packages/coding-agent/src/utils/zip.ts | 17 ++++++++++++++++- packages/coding-agent/test/tools.test.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index 014f04f66..f064b8191 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -729,9 +729,15 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { let offset = 0; let longName: string | undefined; let pax: Map | undefined; + // A valid tar ends with a zero block. Track whether we reach one so an empty + // index can be distinguished from a payload that is not a tar at all. + let sawTerminator = false; while (offset + TAR_BLOCK_SIZE <= buffer.length) { - if (isTarZeroBlock(buffer, offset)) break; + if (isTarZeroBlock(buffer, offset)) { + sawTerminator = true; + break; + } if (!tarChecksumMatches(buffer, offset)) { throw new ToolError("Invalid or corrupt tar archive header"); } @@ -809,6 +815,15 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { }); } + // No entries and no terminating zero block means the decompressed payload + // never presented a complete tar header (e.g. a `.txt.gz`, or a `.tar(.gz)` + // truncated before the first header/terminator). `sniffArchiveFormat` routes + // every gzip magic here, so raise a catchable error instead of rendering it + // as an empty archive directory — callers (fetch) then fall back to binary. + if (entries.length === 0 && !sawTerminator) { + throw new ToolError("Not a valid tar archive: no complete header or terminating block"); + } + return entries; } diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index ec9f46d6a..3a39aafae 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -717,6 +717,18 @@ describe("Coding Agent Tools", () => { await expect(readTool.execute("test-call-tar-truncated", { path: archivePath })).rejects.toThrow(/truncated/); }); + it("should reject a gzip payload that is not a tar archive", async () => { + // `sniffArchiveFormat` classifies any gzip magic as tar.gz, so a plain + // `.txt.gz` (decompressed payload shorter than one 512-byte tar block) + // must raise a catchable error instead of listing an empty directory. + const archivePath = path.join(testDir, "note.tar.gz"); + fs.writeFileSync(archivePath, zlib.gzipSync(Buffer.from("hello world\n"))); + + await expect(readTool.execute("test-call-gzip-non-tar", { path: archivePath })).rejects.toThrow( + /not a valid tar archive/i, + ); + }); + it("should list archive subdirectories", async () => { const archivePath = path.join(testDir, "fixture.zip"); fs.writeFileSync( From 861d5f590989fe022f863ebdf725509b224bfcdc Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 16:11:16 +0000 Subject: [PATCH 4/8] fix(read): preserved tar hard-link members Resolved hard-link targets after indexing so forward links and chains reuse the referenced member's storage and size. Missing, directory, or cyclic targets now surface catchable archive errors instead of silently dropping paths. Fixes #4774 --- packages/coding-agent/src/utils/zip.ts | 75 +++++++++++++++++++++--- packages/coding-agent/test/tools.test.ts | 33 ++++++++++- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index f064b8191..d7ff83972 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -47,9 +47,9 @@ export function unzip(bytes: Uint8Array): Unzipped { } /** - * Cap on the on-disk size of tar/tar.gz archives, which are loaded fully into - * memory (and decompressed by `Bun.Archive`) just to index entries. ZIP is - * exempt: it is read via ranged central-directory access. + * Cap on tar/tar.gz archives loaded fully into memory for in-process indexing + * (gzip input is bounded to this decompressed size). ZIP is exempt: it is read + * via ranged central-directory access. */ const MAX_TAR_ARCHIVE_BYTES = 256 * 1024 * 1024; /** @@ -604,6 +604,8 @@ const TAR_MTIME_LENGTH = 12; const TAR_CHECKSUM_OFFSET = 148; const TAR_CHECKSUM_LENGTH = 8; const TAR_TYPEFLAG_OFFSET = 156; +const TAR_LINKNAME_OFFSET = 157; +const TAR_LINKNAME_LENGTH = 100; const TAR_PREFIX_OFFSET = 345; const TAR_PREFIX_LENGTH = 155; const GZIP_MAGIC_0 = 0x1f; @@ -711,11 +713,12 @@ function paxDeclaresSparse(pax: Map | undefined): boolean { /** * Index a tar (optionally gzip-compressed) archive entirely in TypeScript. * Handles ustar/GNU/pax layouts, `./`-prefixed and `prefix`-split names, GNU - * `@LongLink` long names, and pax `path`/`size` overrides. This deliberately - * avoids `Bun.Archive`/libarchive, whose internal allocation-failure path calls - * `abort()` and takes down the whole process on a crafted or oversized member - * (#4774). Sparse members are indexed but flagged; reading their bytes throws a - * catchable `ToolError` rather than returning a misassembled payload. + * `@LongLink` names/link targets, pax `path`/`linkpath`/`size` overrides, and + * hard links. This deliberately avoids `Bun.Archive`/libarchive, whose + * internal allocation-failure path calls `abort()` and takes down the whole + * process on a crafted or oversized member (#4774). Sparse members are + * indexed but flagged; reading their bytes throws a catchable `ToolError` + * rather than returning a misassembled payload. */ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { let buffer: Uint8Array; @@ -728,7 +731,9 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { const entries: ArchiveIndexEntry[] = []; let offset = 0; let longName: string | undefined; + let longLink: string | undefined; let pax: Map | undefined; + const hardLinks = new Map(); // A valid tar ends with a zero block. Track whether we reach one so an empty // index can be distinguished from a payload that is not a tar at all. let sawTerminator = false; @@ -747,6 +752,7 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { let name = readTarString(buffer, offset + TAR_NAME_OFFSET, TAR_NAME_LENGTH); const prefix = readTarString(buffer, offset + TAR_PREFIX_OFFSET, TAR_PREFIX_LENGTH); if (prefix) name = `${prefix}/${name}`; + let linkName = readTarString(buffer, offset + TAR_LINKNAME_OFFSET, TAR_LINKNAME_LENGTH); const mtime = readTarNumeric(buffer, offset + TAR_MTIME_OFFSET, TAR_MTIME_LENGTH); offset += TAR_BLOCK_SIZE; @@ -760,6 +766,7 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { continue; } if (typeFlag === "K") { + longLink = readTarString(buffer, offset, size); offset += dataBlocks; continue; } @@ -774,8 +781,11 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { } if (longName !== undefined) name = longName; + if (longLink !== undefined) linkName = longLink; const paxPath = pax?.get("path"); if (paxPath !== undefined) name = paxPath; + const paxLinkPath = pax?.get("linkpath"); + if (paxLinkPath !== undefined) linkName = paxLinkPath; const paxSize = pax?.get("size"); if (paxSize !== undefined) { const parsed = Number.parseInt(paxSize, 10); @@ -786,6 +796,7 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { const memberDataBlocks = Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; offset += memberDataBlocks; longName = undefined; + longLink = undefined; pax = undefined; const isDirectory = typeFlag === "5" || name.endsWith("/"); @@ -797,6 +808,21 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { entries.push({ path: normalizedPath, isDirectory: true, size: 0, mtimeMs }); continue; } + if (typeFlag === "1") { + const targetPath = normalizeArchiveEntryPath(linkName); + if (!targetPath) { + throw new ToolError(`Archive hard link '${normalizedPath}' has an invalid target`); + } + const entry: ArchiveIndexEntry = { + path: normalizedPath, + isDirectory: false, + size: 0, + mtimeMs, + }; + entries.push(entry); + hardLinks.set(entry, targetPath); + continue; + } // Only regular-file typeflags carry inline data we can slice. if (typeFlag !== "0" && typeFlag !== "\0" && typeFlag !== "7" && typeFlag !== "S") continue; // A declared payload that runs past the buffer means the archive was @@ -815,6 +841,37 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { }); } + // Hard-link records carry no data. Resolve them after all headers are + // indexed so links may point forward or form chains; missing/cyclic targets + // are archive errors rather than silently omitted paths. + if (hardLinks.size > 0) { + const entriesByPath = new Map(); + for (const entry of entries) entriesByPath.set(entry.path, entry); + + let remaining = hardLinks.size; + while (remaining > 0) { + let resolved = 0; + for (const [entry, targetPath] of hardLinks) { + if (entry.storage) continue; + const target = entriesByPath.get(targetPath); + if (!target) { + throw new ToolError(`Archive hard link '${entry.path}' targets missing member '${targetPath}'`); + } + if (target.isDirectory) { + throw new ToolError(`Archive hard link '${entry.path}' targets directory '${targetPath}'`); + } + if (!target.storage) continue; + entry.size = target.size; + entry.storage = target.storage; + remaining--; + resolved++; + } + if (resolved === 0) { + throw new ToolError("Archive contains cyclic or unsupported hard links"); + } + } + } + // No entries and no terminating zero block means the decompressed payload // never presented a complete tar header (e.g. a `.txt.gz`, or a `.tar(.gz)` // truncated before the first header/terminator). `sniffArchiveFormat` routes @@ -882,7 +939,7 @@ export function parseArchivePathCandidates(filePath: string): ArchivePathCandida /** * An indexed, read-only view over a single archive. ZIP archives are indexed * from the central directory and members are inflated on demand; tar archives - * are fully materialized by `Bun.Archive` up front. + * are parsed from one in-memory buffer and members are sliced on demand. */ export class ArchiveReader { readonly format: ArchiveFormat; diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 3a39aafae..1b752f1b0 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -16,7 +16,7 @@ import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-m import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read"; import * as toolTimeouts from "@oh-my-pi/pi-coding-agent/tools/tool-timeouts"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; -import { unzip } from "@oh-my-pi/pi-coding-agent/utils/zip"; +import { readArchiveEntries, unzip } from "@oh-my-pi/pi-coding-agent/utils/zip"; import { $which, removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; import { GlobTool } from "../src/tools/glob"; import { DEFAULT_FILE_LIMIT, GrepTool, MULTI_FILE_PER_FILE_MATCHES } from "../src/tools/grep"; @@ -65,6 +65,8 @@ interface ArchiveFixtureEntry { path: string; content: string; prefix?: string; + typeFlag?: "0" | "1"; + linkName?: string; } function writeTarString(buffer: Buffer, offset: number, length: number, value: string): void { @@ -87,13 +89,14 @@ function createTarArchive(entries: ArchiveFixtureEntry[]): Buffer { writeTarString(header, 0, 100, entry.path); if (entry.prefix) writeTarString(header, 345, 155, entry.prefix); + if (entry.linkName) writeTarString(header, 157, 100, entry.linkName); writeTarOctal(header, 100, 8, 0o644); writeTarOctal(header, 108, 8, 0); writeTarOctal(header, 116, 8, 0); writeTarOctal(header, 124, 12, content.length); writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000)); header.fill(0x20, 148, 156); - header[156] = "0".charCodeAt(0); + header[156] = (entry.typeFlag ?? "0").charCodeAt(0); writeTarString(header, 257, 6, "ustar"); writeTarString(header, 263, 2, "00"); @@ -707,6 +710,32 @@ describe("Coding Agent Tools", () => { expect(getTextOutput(memberResult)).toContain('{ "type": "module" }'); }); + it("should preserve tar hard-link members", async () => { + const archivePath = path.join(testDir, "hard-link.tar"); + fs.writeFileSync( + archivePath, + createTarArchive([ + { path: "pkg/original.txt", content: "shared content\n" }, + { path: "pkg/linked.txt", content: "", typeFlag: "1", linkName: "pkg/original.txt" }, + ]), + ); + + const rootResult = await readTool.execute("test-call-tar-hard-link-root", { path: `${archivePath}:pkg` }); + expect(getTextOutput(rootResult)).toContain("linked.txt"); + + const linkedResult = await readTool.execute("test-call-tar-hard-link-member", { + path: `${archivePath}:pkg/linked.txt`, + }); + expect(getTextOutput(linkedResult)).toContain("shared content"); + + const entries = await readArchiveEntries(archivePath); + const linkedContent = entries.get("pkg/linked.txt"); + if (!(linkedContent instanceof Uint8Array)) { + throw new Error("Expected hard-link content to materialize as bytes"); + } + expect(new TextDecoder().decode(linkedContent)).toBe("shared content\n"); + }); + it("should reject a truncated tar member while indexing", async () => { const archivePath = path.join(testDir, "truncated.tar"); // A full, valid archive declares 2048 bytes for `big.txt`; slicing the From e1360bbb9dd58f13ff38d86584642ce9bc6b124b Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 21:37:11 +0000 Subject: [PATCH 5/8] fix(read): preserved symlinks and rejected partial tar indexes Resolved safe file and directory symlinks against indexed members, while retaining dangling links as listed nodes that fail explicitly when read or materialized. Required fully buffered tar inputs to reach an end-of-archive zero block so truncated downloads cannot expose partial listings. Fixes #4774 --- packages/coding-agent/src/utils/zip.ts | 160 +++++++++++++++++------ packages/coding-agent/test/tools.test.ts | 65 ++++++++- 2 files changed, 181 insertions(+), 44 deletions(-) diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index d7ff83972..3aee65aa6 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -152,6 +152,11 @@ interface TarStorage { sparse: boolean; } +interface TarLinkStorage { + type: "tar-link"; + targetPath: string; +} + interface ZipStorage { type: "zip"; source: ByteSource; @@ -161,7 +166,7 @@ interface ZipStorage { localHeaderOffset: number; } -type EntryStorage = TarStorage | ZipStorage; +type EntryStorage = TarStorage | TarLinkStorage | ZipStorage; interface ArchiveIndexEntry extends ArchiveNode { storage?: EntryStorage; @@ -733,9 +738,9 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { let longName: string | undefined; let longLink: string | undefined; let pax: Map | undefined; - const hardLinks = new Map(); - // A valid tar ends with a zero block. Track whether we reach one so an empty - // index can be distinguished from a payload that is not a tar at all. + const pendingLinks = new Map(); + // A valid tar ends with a zero block. Track whether the fully buffered input + // reaches one so truncated archives never expose a partial index. let sawTerminator = false; while (offset + TAR_BLOCK_SIZE <= buffer.length) { @@ -808,19 +813,31 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { entries.push({ path: normalizedPath, isDirectory: true, size: 0, mtimeMs }); continue; } - if (typeFlag === "1") { - const targetPath = normalizeArchiveEntryPath(linkName); - if (!targetPath) { - throw new ToolError(`Archive hard link '${normalizedPath}' has an invalid target`); - } + if (typeFlag === "1" || typeFlag === "2") { + const kind = typeFlag === "1" ? "hard link" : "symlink"; + const portableLinkName = linkName.replace(/\\/g, "/"); + const targetPath = + typeFlag === "1" + ? normalizeArchiveEntryPath(portableLinkName) + : path.posix.isAbsolute(portableLinkName) + ? undefined + : normalizeArchiveEntryPath(path.posix.join(path.posix.dirname(normalizedPath), portableLinkName)); const entry: ArchiveIndexEntry = { path: normalizedPath, isDirectory: false, size: 0, mtimeMs, }; + if (!targetPath) { + if (kind === "hard link") { + throw new ToolError(`Archive hard link '${normalizedPath}' has an invalid target`); + } + entry.storage = { type: "tar-link", targetPath: portableLinkName }; + entries.push(entry); + continue; + } entries.push(entry); - hardLinks.set(entry, targetPath); + pendingLinks.set(entry, { kind, targetPath }); continue; } // Only regular-file typeflags carry inline data we can slice. @@ -841,46 +858,85 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { }); } - // Hard-link records carry no data. Resolve them after all headers are - // indexed so links may point forward or form chains; missing/cyclic targets - // are archive errors rather than silently omitted paths. - if (hardLinks.size > 0) { + // Fully buffered tar reads must reach an end-of-archive zero block. Without + // one, even complete entries form only a partial listing: later members may + // have been cut off by a truncated download. For gzip-shaped non-tar input, + // this also gives fetch a catchable error so it can fall back to binary. + if (!sawTerminator) { + throw new ToolError("Not a valid tar archive: missing terminating zero block"); + } + + // Link records carry no data. Resolve them after all headers are indexed so + // hard links and safe relative symlinks may point forward or form chains. + // Directory symlinks materialize the resolved target subtree at the alias + // path, keeping extraction/rewrite behavior useful without writing symlinks + // that could escape the destination. + if (pendingLinks.size > 0) { const entriesByPath = new Map(); for (const entry of entries) entriesByPath.set(entry.path, entry); + const unresolved = new Set(pendingLinks.keys()); - let remaining = hardLinks.size; - while (remaining > 0) { + while (unresolved.size > 0) { let resolved = 0; - for (const [entry, targetPath] of hardLinks) { - if (entry.storage) continue; - const target = entriesByPath.get(targetPath); - if (!target) { - throw new ToolError(`Archive hard link '${entry.path}' targets missing member '${targetPath}'`); + for (const entry of unresolved) { + const pending = pendingLinks.get(entry)!; + const target = entriesByPath.get(pending.targetPath); + if (target?.storage) { + entry.size = target.size; + entry.storage = target.storage; + unresolved.delete(entry); + resolved++; + continue; } - if (target.isDirectory) { - throw new ToolError(`Archive hard link '${entry.path}' targets directory '${targetPath}'`); + if (target && unresolved.has(target)) continue; + + const targetPrefix = `${pending.targetPath}/`; + const targetIsDirectory = + target?.isDirectory === true || entries.some(candidate => candidate.path.startsWith(targetPrefix)); + if (!targetIsDirectory) { + if (pending.kind === "symlink") { + entry.storage = { type: "tar-link", targetPath: pending.targetPath }; + unresolved.delete(entry); + resolved++; + continue; + } + const reason = target ? "unreadable member" : "missing member"; + throw new ToolError(`Archive hard link '${entry.path}' targets ${reason} '${pending.targetPath}'`); } - if (!target.storage) continue; - entry.size = target.size; - entry.storage = target.storage; - remaining--; + if (pending.kind === "hard link") { + throw new ToolError(`Archive hard link '${entry.path}' targets directory '${pending.targetPath}'`); + } + + let hasUnresolvedDescendant = false; + for (const candidate of unresolved) { + if (candidate.path.startsWith(targetPrefix)) { + hasUnresolvedDescendant = true; + break; + } + } + if (hasUnresolvedDescendant) continue; + + entry.isDirectory = true; + const aliasPrefix = `${entry.path}/`; + const sourceCount = entries.length; + for (let index = 0; index < sourceCount; index++) { + const descendant = entries[index]!; + if (!descendant.path.startsWith(targetPrefix)) continue; + const aliasPath = `${aliasPrefix}${descendant.path.slice(targetPrefix.length)}`; + if (entriesByPath.has(aliasPath)) continue; + const alias: ArchiveIndexEntry = { ...descendant, path: aliasPath }; + entries.push(alias); + entriesByPath.set(aliasPath, alias); + } + unresolved.delete(entry); resolved++; } if (resolved === 0) { - throw new ToolError("Archive contains cyclic or unsupported hard links"); + throw new ToolError("Archive contains cyclic or unsupported links"); } } } - // No entries and no terminating zero block means the decompressed payload - // never presented a complete tar header (e.g. a `.txt.gz`, or a `.tar(.gz)` - // truncated before the first header/terminator). `sniffArchiveFormat` routes - // every gzip magic here, so raise a catchable error instead of rendering it - // as an empty archive directory — callers (fetch) then fall back to binary. - if (entries.length === 0 && !sawTerminator) { - throw new ToolError("Not a valid tar archive: no complete header or terminating block"); - } - return entries; } @@ -900,6 +956,12 @@ function extractTarMember(storage: TarStorage, size: number, memberPath: string) return storage.buffer.subarray(storage.dataOffset, end); } +function throwUnreadableTarLink(storage: TarLinkStorage, memberPath: string): never { + throw new ToolError( + `Archive symlink '${memberPath}' cannot be materialized because target '${storage.targetPath}' is unavailable`, + ); +} + async function readZipEntries(source: ByteSource): Promise { const directoryInfo = await readZipCentralDirectoryInfo(source); const centralDirectory = await source.read(directoryInfo.offset, directoryInfo.offset + directoryInfo.size); @@ -1039,11 +1101,14 @@ export class ArchiveReader { ); } - const bytes = - entry.storage.type === "tar" - ? extractTarMember(entry.storage, entry.size, normalizedPath) - : await readZipFileBytes(entry.storage, entry.size); - + let bytes: Uint8Array; + if (entry.storage.type === "tar") { + bytes = extractTarMember(entry.storage, entry.size, normalizedPath); + } else if (entry.storage.type === "tar-link") { + throwUnreadableTarLink(entry.storage, normalizedPath); + } else { + bytes = await readZipFileBytes(entry.storage, entry.size); + } return { path: entry.path, isDirectory: false, @@ -1136,7 +1201,16 @@ export async function readArchiveEntries(source: ArchiveSource): Promise { expect(new TextDecoder().decode(linkedContent)).toBe("shared content\n"); }); + it("should preserve safe relative tar symlink members", async () => { + const archivePath = path.join(testDir, "symlink.tar"); + fs.writeFileSync( + archivePath, + createTarArchive([ + { path: "pkg/lib/tool.js", content: "export const linked = true;\n" }, + { path: "pkg/bin/tool", content: "", typeFlag: "2", linkName: "../lib/tool.js" }, + { path: "pkg/current", content: "", typeFlag: "2", linkName: "lib" }, + ]), + ); + + const linkedResult = await readTool.execute("test-call-tar-symlink-member", { + path: `${archivePath}:pkg/bin/tool`, + }); + expect(getTextOutput(linkedResult)).toContain("export const linked = true"); + + const directoryLinkedResult = await readTool.execute("test-call-tar-directory-symlink-member", { + path: `${archivePath}:pkg/current/tool.js`, + }); + expect(getTextOutput(directoryLinkedResult)).toContain("export const linked = true"); + + const entries = await readArchiveEntries(archivePath); + const linkedContent = entries.get("pkg/bin/tool"); + if (!(linkedContent instanceof Uint8Array)) { + throw new Error("Expected symlink content to materialize as bytes"); + } + expect(new TextDecoder().decode(linkedContent)).toBe("export const linked = true;\n"); + const directoryLinkedContent = entries.get("pkg/current/tool.js"); + if (!(directoryLinkedContent instanceof Uint8Array)) { + throw new Error("Expected directory-symlink content to materialize as bytes"); + } + expect(new TextDecoder().decode(directoryLinkedContent)).toBe("export const linked = true;\n"); + }); + + it("should list dangling tar symlinks but reject their materialization", async () => { + const archivePath = path.join(testDir, "dangling-symlink.tar"); + fs.writeFileSync( + archivePath, + createTarArchive([{ path: "pkg/dangling", content: "", typeFlag: "2", linkName: "missing-target" }]), + ); + + const rootResult = await readTool.execute("test-call-tar-dangling-symlink-root", { + path: `${archivePath}:pkg`, + }); + expect(getTextOutput(rootResult)).toContain("dangling"); + await expect( + readTool.execute("test-call-tar-dangling-symlink-member", { + path: `${archivePath}:pkg/dangling`, + }), + ).rejects.toThrow(/cannot be materialized/); + await expect(readArchiveEntries(archivePath)).rejects.toThrow(/cannot be materialized/); + }); + it("should reject a truncated tar member while indexing", async () => { const archivePath = path.join(testDir, "truncated.tar"); // A full, valid archive declares 2048 bytes for `big.txt`; slicing the @@ -746,6 +799,16 @@ describe("Coding Agent Tools", () => { await expect(readTool.execute("test-call-tar-truncated", { path: archivePath })).rejects.toThrow(/truncated/); }); + it("should reject a tar truncated before its terminating zero block", async () => { + const archivePath = path.join(testDir, "unterminated.tar"); + const complete = createTarArchive([{ path: "complete.txt", content: "complete member\n" }]); + fs.writeFileSync(archivePath, complete.subarray(0, complete.length - 1024)); + + await expect(readTool.execute("test-call-tar-unterminated", { path: archivePath })).rejects.toThrow( + /missing terminating zero block/, + ); + }); + it("should reject a gzip payload that is not a tar archive", async () => { // `sniffArchiveFormat` classifies any gzip magic as tar.gz, so a plain // `.txt.gz` (decompressed payload shorter than one 512-byte tar block) From 40ce90866e8d05d5d539e4fd4ddf738dcd25e401 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 21:47:45 +0000 Subject: [PATCH 6/8] fix(read): resolved tar directory symlinks lazily Kept directory symlinks as one alias node and rewrote requested paths through aliases in ArchiveReader instead of cloning every target descendant during indexing. Full archive materialization now fails explicitly on directory aliases rather than expanding them without a bound. Fixes #4774 --- packages/coding-agent/src/utils/zip.ts | 94 +++++++++++++----------- packages/coding-agent/test/tools.test.ts | 41 +++++++---- 2 files changed, 81 insertions(+), 54 deletions(-) diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index 3aee65aa6..d8a879b72 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -866,11 +866,10 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { throw new ToolError("Not a valid tar archive: missing terminating zero block"); } - // Link records carry no data. Resolve them after all headers are indexed so - // hard links and safe relative symlinks may point forward or form chains. - // Directory symlinks materialize the resolved target subtree at the alias - // path, keeping extraction/rewrite behavior useful without writing symlinks - // that could escape the destination. + // Link records carry no data. Resolve file targets after all headers are + // indexed; directory symlinks remain one alias node and are traversed lazily + // by ArchiveReader so N files behind M aliases never inflate the index to + // N×M entries during a root listing. if (pendingLinks.size > 0) { const entriesByPath = new Map(); for (const entry of entries) entriesByPath.set(entry.path, entry); @@ -881,7 +880,7 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { for (const entry of unresolved) { const pending = pendingLinks.get(entry)!; const target = entriesByPath.get(pending.targetPath); - if (target?.storage) { + if (target?.storage && !target.isDirectory) { entry.size = target.size; entry.storage = target.storage; unresolved.delete(entry); @@ -907,27 +906,8 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { throw new ToolError(`Archive hard link '${entry.path}' targets directory '${pending.targetPath}'`); } - let hasUnresolvedDescendant = false; - for (const candidate of unresolved) { - if (candidate.path.startsWith(targetPrefix)) { - hasUnresolvedDescendant = true; - break; - } - } - if (hasUnresolvedDescendant) continue; - entry.isDirectory = true; - const aliasPrefix = `${entry.path}/`; - const sourceCount = entries.length; - for (let index = 0; index < sourceCount; index++) { - const descendant = entries[index]!; - if (!descendant.path.startsWith(targetPrefix)) continue; - const aliasPath = `${aliasPrefix}${descendant.path.slice(targetPrefix.length)}`; - if (entriesByPath.has(aliasPath)) continue; - const alias: ArchiveIndexEntry = { ...descendant, path: aliasPath }; - entries.push(alias); - entriesByPath.set(aliasPath, alias); - } + entry.storage = { type: "tar-link", targetPath: pending.targetPath }; unresolved.delete(entry); resolved++; } @@ -957,9 +937,7 @@ function extractTarMember(storage: TarStorage, size: number, memberPath: string) } function throwUnreadableTarLink(storage: TarLinkStorage, memberPath: string): never { - throw new ToolError( - `Archive symlink '${memberPath}' cannot be materialized because target '${storage.targetPath}' is unavailable`, - ); + throw new ToolError(`Archive symlink '${memberPath}' cannot be materialized from target '${storage.targetPath}'`); } async function readZipEntries(source: ByteSource): Promise { @@ -1001,7 +979,8 @@ export function parseArchivePathCandidates(filePath: string): ArchivePathCandida /** * An indexed, read-only view over a single archive. ZIP archives are indexed * from the central directory and members are inflated on demand; tar archives - * are parsed from one in-memory buffer and members are sliced on demand. + * are parsed from one in-memory buffer, members are sliced on demand, and + * directory symlink aliases are traversed lazily. */ export class ArchiveReader { readonly format: ArchiveFormat; @@ -1015,6 +994,30 @@ export class ArchiveReader { ensureParentDirectories(this.#entries); } + #resolveDirectoryAliases(archivePath: string): string { + let resolvedPath = archivePath; + const seen = new Set(); + while (true) { + if (seen.has(resolvedPath)) { + throw new ToolError(`Archive path '${archivePath}' crosses a cyclic symlink`); + } + seen.add(resolvedPath); + + const parts = resolvedPath.split("/"); + let replacement: string | undefined; + for (let end = parts.length; end > 0; end--) { + const prefix = parts.slice(0, end).join("/"); + const entry = this.#entries.get(prefix); + if (!entry?.isDirectory || entry.storage?.type !== "tar-link") continue; + const suffix = parts.slice(end).join("/"); + replacement = suffix ? `${entry.storage.targetPath}/${suffix}` : entry.storage.targetPath; + break; + } + if (replacement === undefined) return resolvedPath; + resolvedPath = replacement; + } + } + getNode(subPath?: string): ArchiveNode | undefined { const normalizedPath = normalizeArchiveLookupPath(subPath); if (normalizedPath === undefined) return undefined; @@ -1022,10 +1025,11 @@ export class ArchiveReader { return { path: "", isDirectory: true, size: 0 }; } - const entry = this.#entries.get(normalizedPath); + const resolvedPath = this.#resolveDirectoryAliases(normalizedPath); + const entry = this.#entries.get(resolvedPath); if (!entry) return undefined; return { - path: entry.path, + path: normalizedPath, isDirectory: entry.isDirectory, size: entry.size, mtimeMs: entry.mtimeMs, @@ -1038,8 +1042,9 @@ export class ArchiveReader { throw new ToolError("Archive path cannot contain '..'"); } + const resolvedPath = normalizedPath ? this.#resolveDirectoryAliases(normalizedPath) : ""; if (normalizedPath) { - const entry = this.#entries.get(normalizedPath); + const entry = this.#entries.get(resolvedPath); if (!entry) { throw new ToolError(`Archive path '${normalizedPath}' not found`); } @@ -1048,22 +1053,23 @@ export class ArchiveReader { } } - const prefix = normalizedPath ? `${normalizedPath}/` : ""; + const sourcePrefix = resolvedPath ? `${resolvedPath}/` : ""; const children = new Map(); for (const entry of this.#entries.values()) { - if (normalizedPath) { - if (!entry.path.startsWith(prefix) || entry.path === normalizedPath) continue; + if (resolvedPath) { + if (!entry.path.startsWith(sourcePrefix) || entry.path === resolvedPath) continue; } - const relativePath = normalizedPath ? entry.path.slice(prefix.length) : entry.path; + const relativePath = resolvedPath ? entry.path.slice(sourcePrefix.length) : entry.path; const nextSegment = relativePath.split("/")[0]; if (!nextSegment) continue; const childPath = normalizedPath ? `${normalizedPath}/${nextSegment}` : nextSegment; if (children.has(childPath)) continue; - const childEntry = this.#entries.get(childPath); + const sourceChildPath = resolvedPath ? `${resolvedPath}/${nextSegment}` : nextSegment; + const childEntry = this.#entries.get(sourceChildPath); const isDirectory = childEntry?.isDirectory ?? relativePath.includes("/"); children.set(childPath, { name: nextSegment, @@ -1085,7 +1091,8 @@ export class ArchiveReader { throw new ToolError("Archive file path is required"); } - const entry = this.#entries.get(normalizedPath); + const resolvedPath = this.#resolveDirectoryAliases(normalizedPath); + const entry = this.#entries.get(resolvedPath); if (!entry) { throw new ToolError(`Archive file '${normalizedPath}' not found`); } @@ -1110,7 +1117,7 @@ export class ArchiveReader { bytes = await readZipFileBytes(entry.storage, entry.size); } return { - path: entry.path, + path: normalizedPath, isDirectory: false, size: entry.size, mtimeMs: entry.mtimeMs, @@ -1201,7 +1208,12 @@ export async function readArchiveEntries(source: ArchiveSource): Promise { expect(new TextDecoder().decode(linkedContent)).toBe("shared content\n"); }); - it("should preserve safe relative tar symlink members", async () => { - const archivePath = path.join(testDir, "symlink.tar"); + it("should preserve safe relative tar file symlinks", async () => { + const archivePath = path.join(testDir, "file-symlink.tar"); fs.writeFileSync( archivePath, createTarArchive([ { path: "pkg/lib/tool.js", content: "export const linked = true;\n" }, { path: "pkg/bin/tool", content: "", typeFlag: "2", linkName: "../lib/tool.js" }, - { path: "pkg/current", content: "", typeFlag: "2", linkName: "lib" }, ]), ); @@ -752,22 +751,38 @@ describe("Coding Agent Tools", () => { }); expect(getTextOutput(linkedResult)).toContain("export const linked = true"); - const directoryLinkedResult = await readTool.execute("test-call-tar-directory-symlink-member", { - path: `${archivePath}:pkg/current/tool.js`, - }); - expect(getTextOutput(directoryLinkedResult)).toContain("export const linked = true"); - const entries = await readArchiveEntries(archivePath); const linkedContent = entries.get("pkg/bin/tool"); if (!(linkedContent instanceof Uint8Array)) { throw new Error("Expected symlink content to materialize as bytes"); } expect(new TextDecoder().decode(linkedContent)).toBe("export const linked = true;\n"); - const directoryLinkedContent = entries.get("pkg/current/tool.js"); - if (!(directoryLinkedContent instanceof Uint8Array)) { - throw new Error("Expected directory-symlink content to materialize as bytes"); - } - expect(new TextDecoder().decode(directoryLinkedContent)).toBe("export const linked = true;\n"); + }); + + it("should resolve directory symlinks lazily without materializing subtrees", async () => { + const archivePath = path.join(testDir, "directory-symlinks.tar"); + fs.writeFileSync( + archivePath, + createTarArchive([ + { path: "pkg/lib/tool.js", content: "export const linked = true;\n" }, + { path: "pkg/lib/extra.js", content: "export const extra = true;\n" }, + { path: "pkg/current-a", content: "", typeFlag: "2", linkName: "lib" }, + { path: "pkg/current-b", content: "", typeFlag: "2", linkName: "lib" }, + { path: "pkg/current-c", content: "", typeFlag: "2", linkName: "lib" }, + ]), + ); + + const linkedResult = await readTool.execute("test-call-tar-directory-symlink-member", { + path: `${archivePath}:pkg/current-a/tool.js`, + }); + expect(getTextOutput(linkedResult)).toContain("export const linked = true"); + + const directoryResult = await readTool.execute("test-call-tar-directory-symlink-directory", { + path: `${archivePath}:pkg/current-b`, + }); + expect(getTextOutput(directoryResult)).toContain("extra.js"); + + await expect(readArchiveEntries(archivePath)).rejects.toThrow(/cannot be materialized/); }); it("should list dangling tar symlinks but reject their materialization", async () => { From 1df67cc6675aacc72ccc0fed3b8520c26b6e2a84 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 21:57:00 +0000 Subject: [PATCH 7/8] fix(read): honored GNU sparse PAX member names GNU 1.0 sparse PAX entries now list under GNU.sparse.name with GNU.sparse.realsize as the displayed size, so root listings no longer expose the internal GNUSparseFile path and reads of the real name reject as sparse instead of reporting the member missing. The on-disk header size still drives offset advance and truncation. Fixes #4774 --- packages/coding-agent/src/utils/zip.ts | 16 ++++- packages/coding-agent/test/tools.test.ts | 81 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index d8a879b72..e105c28a5 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -796,6 +796,20 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { const parsed = Number.parseInt(paxSize, 10); if (Number.isFinite(parsed) && parsed >= 0) size = parsed; } + // GNU 1.0 sparse PAX stores the user-visible path in a dedicated record + // while the file header carries an internal `GNUSparseFile.NNN` name. + // Surface the real name so listings and `read :` resolve + // the member (its bytes are still rejected as sparse below). The header + // `size` remains the on-disk stored length that drives offset advance + // and truncation; `GNU.sparse.realsize` is display-only. + const paxSparseName = pax?.get("GNU.sparse.name"); + if (paxSparseName !== undefined) name = paxSparseName; + let displaySize = size; + const paxSparseRealSize = pax?.get("GNU.sparse.realsize"); + if (paxSparseRealSize !== undefined) { + const parsed = Number.parseInt(paxSparseRealSize, 10); + if (Number.isFinite(parsed) && parsed >= 0) displaySize = parsed; + } const sparse = typeFlag === "S" || paxDeclaresSparse(pax); const dataOffset = offset; const memberDataBlocks = Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; @@ -852,7 +866,7 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { entries.push({ path: normalizedPath, isDirectory: false, - size, + size: displaySize, mtimeMs, storage: { type: "tar", buffer, dataOffset, sparse }, }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 6b7f64030..616ad4a80 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -118,6 +118,67 @@ function createTarArchive(entries: ArchiveFixtureEntry[]): Buffer { return Buffer.concat(parts); } +function tarChecksum(header: Buffer): void { + header.fill(0x20, 148, 156); + let checksum = 0; + for (const byte of header) checksum += byte; + header.write(checksum.toString(8).padStart(6, "0"), 148, 6, "ascii"); + header[154] = 0; + header[155] = 0x20; +} + +function paxRecord(key: string, value: string): Buffer { + const suffix = ` ${key}=${value}\n`; + let length = suffix.length; + while (`${length}`.length + suffix.length !== length) { + length = `${length}`.length + suffix.length; + } + return Buffer.from(`${length}${suffix}`, "utf-8"); +} + +/** + * Build a GNU 1.0 sparse PAX archive: an `x` extended header carrying the + * user-visible `GNU.sparse.name`/`GNU.sparse.realsize`, followed by a regular + * file header using the internal `GNUSparseFile.NNN` path. + */ +function createSparsePaxTarArchive(realName: string, realSize: number, storedData: Buffer): Buffer { + const paxBody = Buffer.concat([ + paxRecord("GNU.sparse.major", "1"), + paxRecord("GNU.sparse.minor", "0"), + paxRecord("GNU.sparse.name", realName), + paxRecord("GNU.sparse.realsize", `${realSize}`), + paxRecord("size", `${storedData.length}`), + ]); + const paxHeader = Buffer.alloc(512, 0); + writeTarString(paxHeader, 0, 100, "./PaxHeaders/sparse"); + writeTarOctal(paxHeader, 100, 8, 0o644); + writeTarOctal(paxHeader, 124, 12, paxBody.length); + writeTarOctal(paxHeader, 136, 12, Math.floor(Date.now() / 1000)); + paxHeader[156] = "x".charCodeAt(0); + writeTarString(paxHeader, 257, 6, "ustar"); + writeTarString(paxHeader, 263, 2, "00"); + tarChecksum(paxHeader); + + const fileHeader = Buffer.alloc(512, 0); + writeTarString(fileHeader, 0, 100, "./GNUSparseFile.0/sparse.bin"); + writeTarOctal(fileHeader, 100, 8, 0o644); + writeTarOctal(fileHeader, 124, 12, storedData.length); + writeTarOctal(fileHeader, 136, 12, Math.floor(Date.now() / 1000)); + fileHeader[156] = "0".charCodeAt(0); + writeTarString(fileHeader, 257, 6, "ustar"); + writeTarString(fileHeader, 263, 2, "00"); + tarChecksum(fileHeader); + + const parts: Buffer[] = [paxHeader]; + const paxRemainder = paxBody.length % 512; + parts.push(paxBody, paxRemainder === 0 ? Buffer.alloc(0) : Buffer.alloc(512 - paxRemainder, 0)); + parts.push(fileHeader, storedData); + const dataRemainder = storedData.length % 512; + if (dataRemainder !== 0) parts.push(Buffer.alloc(512 - dataRemainder, 0)); + parts.push(Buffer.alloc(1024, 0)); + return Buffer.concat(parts); +} + const CRC32_TABLE = (() => { const table = new Uint32Array(256); for (let index = 0; index < 256; index++) { @@ -804,6 +865,26 @@ describe("Coding Agent Tools", () => { await expect(readArchiveEntries(archivePath)).rejects.toThrow(/cannot be materialized/); }); + it("should surface GNU sparse PAX names and reject sparse reads", async () => { + const archivePath = path.join(testDir, "sparse-pax.tar"); + fs.writeFileSync( + archivePath, + createSparsePaxTarArchive("data/sparse.bin", 1048576, Buffer.from("sparse-map\n")), + ); + + // The listing must show the real GNU.sparse.name, not the internal + // GNUSparseFile.NNN path. + const rootResult = await readTool.execute("test-call-tar-sparse-root", { path: `${archivePath}:data` }); + expect(getTextOutput(rootResult)).toContain("sparse.bin"); + expect(getTextOutput(rootResult)).not.toContain("GNUSparseFile"); + + // Reading the real member name resolves the entry and rejects it as + // sparse (a catchable error), rather than reporting it missing. + await expect( + readTool.execute("test-call-tar-sparse-member", { path: `${archivePath}:data/sparse.bin` }), + ).rejects.toThrow(/sparse file and cannot be read/); + }); + it("should reject a truncated tar member while indexing", async () => { const archivePath = path.join(testDir, "truncated.tar"); // A full, valid archive declares 2048 bytes for `big.txt`; slicing the From f30a60b797a2fe5b9ba52ec38e167ebf4ed5ae41 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 22:06:41 +0000 Subject: [PATCH 8/8] fix(read): resolved archive-root symlink targets Symlink targets that normalize to the archive root (current -> ., dir/up -> ..) now resolve as directory aliases to the root instead of being treated as dangling links. The lookup normalizer distinguishes an empty root target from an escaping target, and ArchiveReader treats a resolved-empty path as the root directory. Fixes #4774 --- packages/coding-agent/src/utils/zip.ts | 27 +++++++++++++++++++----- packages/coding-agent/test/tools.test.ts | 24 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/utils/zip.ts b/packages/coding-agent/src/utils/zip.ts index e105c28a5..dae0bf9bd 100644 --- a/packages/coding-agent/src/utils/zip.ts +++ b/packages/coding-agent/src/utils/zip.ts @@ -830,19 +830,23 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { if (typeFlag === "1" || typeFlag === "2") { const kind = typeFlag === "1" ? "hard link" : "symlink"; const portableLinkName = linkName.replace(/\\/g, "/"); + // Symlinks resolve relative to their own directory; a target that + // stays inside the archive normalizes to a member path or "" (the + // archive root, e.g. `current -> .`). `undefined` means the target + // escapes the root (or is absolute) and is kept as a dangling link. const targetPath = typeFlag === "1" ? normalizeArchiveEntryPath(portableLinkName) : path.posix.isAbsolute(portableLinkName) ? undefined - : normalizeArchiveEntryPath(path.posix.join(path.posix.dirname(normalizedPath), portableLinkName)); + : normalizeArchiveLookupPath(path.posix.join(path.posix.dirname(normalizedPath), portableLinkName)); const entry: ArchiveIndexEntry = { path: normalizedPath, isDirectory: false, size: 0, mtimeMs, }; - if (!targetPath) { + if (targetPath === undefined) { if (kind === "hard link") { throw new ToolError(`Archive hard link '${normalizedPath}' has an invalid target`); } @@ -903,9 +907,12 @@ function readTarEntries(rawBytes: Uint8Array): ArchiveIndexEntry[] { } if (target && unresolved.has(target)) continue; + // An empty target is the archive root, which is always a directory. const targetPrefix = `${pending.targetPath}/`; const targetIsDirectory = - target?.isDirectory === true || entries.some(candidate => candidate.path.startsWith(targetPrefix)); + pending.targetPath === "" || + target?.isDirectory === true || + entries.some(candidate => candidate.path.startsWith(targetPrefix)); if (!targetIsDirectory) { if (pending.kind === "symlink") { entry.storage = { type: "tar-link", targetPath: pending.targetPath }; @@ -1024,7 +1031,11 @@ export class ArchiveReader { const entry = this.#entries.get(prefix); if (!entry?.isDirectory || entry.storage?.type !== "tar-link") continue; const suffix = parts.slice(end).join("/"); - replacement = suffix ? `${entry.storage.targetPath}/${suffix}` : entry.storage.targetPath; + replacement = suffix + ? entry.storage.targetPath + ? `${entry.storage.targetPath}/${suffix}` + : suffix + : entry.storage.targetPath; break; } if (replacement === undefined) return resolvedPath; @@ -1040,6 +1051,9 @@ export class ArchiveReader { } const resolvedPath = this.#resolveDirectoryAliases(normalizedPath); + if (resolvedPath === "") { + return { path: normalizedPath, isDirectory: true, size: 0 }; + } const entry = this.#entries.get(resolvedPath); if (!entry) return undefined; return { @@ -1057,7 +1071,7 @@ export class ArchiveReader { } const resolvedPath = normalizedPath ? this.#resolveDirectoryAliases(normalizedPath) : ""; - if (normalizedPath) { + if (normalizedPath && resolvedPath !== "") { const entry = this.#entries.get(resolvedPath); if (!entry) { throw new ToolError(`Archive path '${normalizedPath}' not found`); @@ -1106,6 +1120,9 @@ export class ArchiveReader { } const resolvedPath = this.#resolveDirectoryAliases(normalizedPath); + if (resolvedPath === "") { + throw new ToolError(`Archive path '${normalizedPath}' is a directory`); + } const entry = this.#entries.get(resolvedPath); if (!entry) { throw new ToolError(`Archive file '${normalizedPath}' not found`); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 616ad4a80..bb3265366 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -846,6 +846,30 @@ describe("Coding Agent Tools", () => { await expect(readArchiveEntries(archivePath)).rejects.toThrow(/cannot be materialized/); }); + it("should resolve tar symlinks whose target is the archive root", async () => { + const archivePath = path.join(testDir, "root-symlinks.tar"); + fs.writeFileSync( + archivePath, + createTarArchive([ + { path: "top.txt", content: "top level\n" }, + { path: "dir/inner.txt", content: "inner\n" }, + // `current -> .` and `dir/up -> ..` both normalize to the archive root. + { path: "current", content: "", typeFlag: "2", linkName: "." }, + { path: "dir/up", content: "", typeFlag: "2", linkName: ".." }, + ]), + ); + + const currentNode = await readTool.execute("test-call-tar-root-symlink-current", { + path: `${archivePath}:current/top.txt`, + }); + expect(getTextOutput(currentNode)).toContain("top level"); + + const upNode = await readTool.execute("test-call-tar-root-symlink-up", { + path: `${archivePath}:dir/up/top.txt`, + }); + expect(getTextOutput(upNode)).toContain("top level"); + }); + it("should list dangling tar symlinks but reject their materialization", async () => { const archivePath = path.join(testDir, "dangling-symlink.tar"); fs.writeFileSync(