diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts index 426467122b..ca0c9a7448 100644 --- a/src/artifacts/apple/DylibResolutionReader.ts +++ b/src/artifacts/apple/DylibResolutionReader.ts @@ -17,6 +17,10 @@ import { import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; import { DyldSharedCache } from "./DyldSharedCacheReader.js"; +import { + filesystemDyldLinkTarget, + filesystemDyldLookupPath, +} from "./FilesystemDyldPaths.js"; import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js"; const HASH_CHUNK_BYTES = 1024 * 1024; @@ -63,11 +67,14 @@ class FilesystemTreeView implements DylibTreeView { async #readEntry(path: string): Promise { cancelled(this.signal); - const absolute = join(this.root, path); + const absolute = filesystemDyldLookupPath(this.root, path); try { const metadata = await lstat(absolute); if (metadata.isSymbolicLink()) - return { kind: "symlink", target: await readlink(absolute) }; + return { + kind: "symlink", + target: filesystemDyldLinkTarget(await readlink(absolute)), + }; if (metadata.isDirectory()) return { kind: "directory" }; return metadata.isFile() ? { kind: "file" } : undefined; } catch (cause: unknown) { diff --git a/src/artifacts/apple/FilesystemDyldPaths.test.ts b/src/artifacts/apple/FilesystemDyldPaths.test.ts new file mode 100644 index 0000000000..cee68e6da7 --- /dev/null +++ b/src/artifacts/apple/FilesystemDyldPaths.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; +import { resolveTreePath } from "../../domain/apple/dyldPaths.js"; +import type { DylibTreeEntry } from "../../domain/apple/dylibResolution.js"; +import { + filesystemDyldLinkTarget, + filesystemDyldLookupPath, +} from "./FilesystemDyldPaths.js"; + +it("normalizes Windows relative links before containment checks without probing an escaped path", async () => { + const requested: string[] = []; + const result = await resolveTreePath( + { + entry: async (path: string): Promise => { + requested.push(path); + return path === "link" + ? { + kind: "symlink", + target: filesystemDyldLinkTarget("..\\outside.dylib", "win32"), + } + : undefined; + }, + image: async () => ({ status: "not-mach-o" }), + }, + "link", + ); + expect(result).toEqual({ kind: "escapes" }); + expect(requested).toEqual(["link"]); +}); + +it("keeps POSIX backslash bytes and preserves Windows dot segments for segment-wise traversal", () => { + expect(filesystemDyldLinkTarget("..\\outside.dylib", "darwin")).toBe( + "..\\outside.dylib", + ); + expect(filesystemDyldLinkTarget("nested\\..\\target", "win32")).toBe( + "nested/../target", + ); + expect(filesystemDyldLinkTarget("C:\\outside", "win32")).toBe("/C:/outside"); + expect(filesystemDyldLinkTarget("C:/literal-posix-directory", "darwin")).toBe( + "C:/literal-posix-directory", + ); + expect(() => filesystemDyldLinkTarget("C:drive-relative", "win32")).toThrow( + "Windows drive working directory", + ); +}); + +it("does not let Windows reinterpret a literal backslash-bearing loader name as traversal", () => { + expect(() => + filesystemDyldLookupPath("C:\\root", "..\\outside.dylib", "win32"), + ).toThrow("cannot be represented on Windows"); + expect( + filesystemDyldLookupPath("/root", "literal\\name.dylib", "darwin"), + ).toBe("/root/literal\\name.dylib"); +}); diff --git a/src/artifacts/apple/FilesystemDyldPaths.ts b/src/artifacts/apple/FilesystemDyldPaths.ts new file mode 100644 index 0000000000..bff0d2b37b --- /dev/null +++ b/src/artifacts/apple/FilesystemDyldPaths.ts @@ -0,0 +1,39 @@ +import { posix, win32 } from "node:path"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; + +/** + * Convert host filesystem separators into the tree view's POSIX traversal + * namespace. Dot segments stay intact so they apply after symlink expansion; + * POSIX backslash filename bytes are never reinterpreted as separators. + */ +export const filesystemDyldLinkTarget = ( + reported: string, + platform: NodeJS.Platform = process.platform, +): string => { + if (platform !== "win32") return reported; + const portable = reported.replaceAll("\\", "/"); + if (win32.isAbsolute(reported)) + return portable.startsWith("/") ? portable : `/${portable}`; + if (/^[A-Za-z]:/u.test(reported)) + throw new ArtifactReaderFailure( + "unavailable", + `Drive-relative symlink target depends on a Windows drive working directory: ${reported}`, + ); + return portable; +}; + +/** Map a portable lookup to the host without allowing extra native separators to escape it. */ +export const filesystemDyldLookupPath = ( + root: string, + lookup: string, + platform: NodeJS.Platform = process.platform, +): string => { + if (platform === "win32" && lookup.includes("\\")) + throw new ArtifactReaderFailure( + "unavailable", + `Literal backslashes in a POSIX dyld lookup cannot be represented on Windows: ${lookup}. Analyze this tree on a POSIX host.`, + ); + return platform === "win32" + ? win32.join(root, lookup) + : posix.join(root, lookup); +}; diff --git a/src/domain/apple/dyldPaths.ts b/src/domain/apple/dyldPaths.ts index ce0c69785c..d1a4b45bbd 100644 --- a/src/domain/apple/dyldPaths.ts +++ b/src/domain/apple/dyldPaths.ts @@ -37,13 +37,8 @@ export const resolveTreePath = async ( if (entry.kind === "symlink") { hops += 1; if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" }; - if ( - entry.target.startsWith("/") || - /^[a-zA-Z]:[\\/]/u.test(entry.target) || - entry.target.startsWith("\\\\") || - entry.target.startsWith("//") - ) - return { kind: "escapes" }; + // Tree providers convert host link syntax to this POSIX namespace. + if (entry.target.startsWith("/")) return { kind: "escapes" }; pending.unshift(...segmentsOf(entry.target)); continue; }