From 5940600a22763c6dea5eced7ab628485aeabccbf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:43:56 +0800 Subject: [PATCH] fix(reference): preserve historical backslash import bases --- docs/cli.md | 7 ++++++ .../ReferenceSourceImportEntries.ts | 4 ++-- .../referenceSourcePathIdentity.test.ts | 23 ++++++++++++++++++- src/domain/referenceSourcePathIdentity.ts | 22 ++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 53ee0bd3ed..deed003531 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -505,6 +505,13 @@ by file order. Omit `--evidence-bundle` when the request supplies inline Evidenc ## Import historical source +Historical source inventories preserve exact filenames, including literal backslashes +on POSIX hosts. Relative import lookup uses the recorded source file's owning +directory and decodes URL percent escapes once; encoded slash, NUL and malformed +escapes remain unresolved. A decoded backslash remains part of a filename rather +than a directory separator. This inventory lookup does not claim that Node can +load that filename or apply Node's runtime file-URL admission policy. + Import an older source tree to compare with the current artifact: ```bash diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 61d09534f3..1fac687310 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -3,10 +3,10 @@ import { createHash } from "node:crypto"; import { posix } from "node:path"; import type { JavaScriptPackageType } from "../domain/javascript/javascriptSourceParser.js"; -import { decodeJavaScriptModuleUrlPath } from "../domain/javascript/javascriptModuleSpecifier.js"; import { logicalPathEscapesRoot } from "../domain/artifactIdentity.js"; import { createReferenceSourcePathLookup, + decodeReferenceSourceModuleUrlPath, isPortableAbsoluteReferenceTarget, type ReferenceSourcePathLookup, } from "../domain/referenceSourcePathIdentity.js"; @@ -322,7 +322,7 @@ const importLookupPath = ( ); if (!target.pathname.startsWith("/rea-reference/")) return err("The module URL escapes the selected source inventory."); - const decoded = decodeJavaScriptModuleUrlPath( + const decoded = decodeReferenceSourceModuleUrlPath( target.pathname.slice("/rea-reference/".length), ); return decoded.ok diff --git a/src/domain/referenceSourcePathIdentity.test.ts b/src/domain/referenceSourcePathIdentity.test.ts index 56b1775623..976c3691ff 100644 --- a/src/domain/referenceSourcePathIdentity.test.ts +++ b/src/domain/referenceSourcePathIdentity.test.ts @@ -1,6 +1,9 @@ import { expect, it } from "vitest"; -import { createReferenceSourcePathLookup } from "./referenceSourcePathIdentity.js"; +import { + createReferenceSourcePathLookup, + decodeReferenceSourceModuleUrlPath, +} from "./referenceSourcePathIdentity.js"; it("reports ambiguous canonical identities while preserving exact paths", () => { const first = "package/a\u0301\u0323/package.json"; @@ -13,3 +16,21 @@ it("reports ambiguous canonical identities while preserving exact paths", () => expect(lookup.resolve(first)).toBe(first); expect(lookup.resolve(second)).toBe(second); }); + +it.each([ + ["literal%5Cdir/dep.js", "literal\\dir/dep.js"], + ["literal%255Cdir/dep.js", "literal%5Cdir/dep.js"], + ["literal%252Fdir/dep.js", "literal%2Fdir/dep.js"], +])("decodes historical inventory bytes once: %s", (encoded, path) => { + expect(decodeReferenceSourceModuleUrlPath(encoded)).toEqual({ + ok: true, + value: path, + }); +}); + +it.each(["dir%2Fdep.js", "dir%2fdep.js", "dir/%00dep.js", "dir/%invalid.js"])( + "refuses malformed historical module paths: %s", + (encoded) => { + expect(decodeReferenceSourceModuleUrlPath(encoded).ok).toBe(false); + }, +); diff --git a/src/domain/referenceSourcePathIdentity.ts b/src/domain/referenceSourcePathIdentity.ts index 76404c83b0..9a1718973d 100644 --- a/src/domain/referenceSourcePathIdentity.ts +++ b/src/domain/referenceSourcePathIdentity.ts @@ -1,4 +1,5 @@ import { posix } from "node:path"; +import { err, ok, type Result } from "./result.js"; export interface ReferenceSourcePathLookup { /** Whether an exact or canonical identity exists, including ambiguous ones. */ @@ -53,3 +54,24 @@ export const isPortableAbsoluteReferenceTarget = (target: string): boolean => target.startsWith("/") || /^[A-Za-z]:[\\/]/u.test(target) || /^\\\\[^\\/]+[\\/][^\\/]+/u.test(target); + +/** Decode one historical inventory URL path; backslash remains a filename byte. */ +export const decodeReferenceSourceModuleUrlPath = ( + value: string, +): Result => { + if (/%2f/iu.test(value)) + return err( + "Historical module URL paths cannot contain percent-encoded slash bytes.", + ); + try { + const decoded = decodeURIComponent(value); + return decoded.includes("\0") + ? err("The decoded historical module URL path contains NUL.") + : ok(decoded); + } catch (cause: unknown) { + if (!(cause instanceof URIError)) throw cause; + return err( + "The historical module URL path contains malformed percent encoding.", + ); + } +};