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:
@@ -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`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user