From f30a60b797a2fe5b9ba52ec38e167ebf4ed5ae41 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 11 Aug 2026 22:06:41 +0000 Subject: [PATCH] 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(