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
This commit is contained in:
roboomp
2026-08-11 22:06:41 +00:00
parent 1df67cc667
commit f30a60b797
2 changed files with 46 additions and 5 deletions
+22 -5
View File
@@ -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`);
+24
View File
@@ -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(