diff --git a/docs/guides/native/windows-shortcut.md b/docs/guides/native/windows-shortcut.md index 548ad6421c..feadfa18a1 100644 --- a/docs/guides/native/windows-shortcut.md +++ b/docs/guides/native/windows-shortcut.md @@ -62,7 +62,10 @@ arguments are present, argument character length, target extension, whether `ShowCommand` is minimized, and whether the icon location differs from the recorded target path. That comparison is `null` unless the icon and a recorded target are both full paths: a stored filename, relative path, or `%VAR%` form -cannot show that two locations differ. This workflow does not emit verdicts +cannot show that two locations differ. Windows stores the `LinkInfo` path only +as ANSI when the system code page can represent it. An ANSI path with non-ASCII +bytes is therefore not compared with a Unicode icon location, or the reverse, +because the code page is unobserved. This workflow does not emit verdicts about intent. A missing optional section is observed absence of that structure. It is not diff --git a/src/native/windows/WindowsShortcutParser.test.ts b/src/native/windows/WindowsShortcutParser.test.ts index bbe1831c9b..20c66ce0e0 100644 --- a/src/native/windows/WindowsShortcutParser.test.ts +++ b/src/native/windows/WindowsShortcutParser.test.ts @@ -644,6 +644,96 @@ it("compares an icon with a full target path, ignoring case and separators", asy expect(iconDifference(different)).toBe(true); }); +it("compares an icon with a target only in a shared or ASCII encoding", async () => { + // Windows stores the LinkInfo path only as ANSI when the system code page can + // represent it. 0xB9 0xA4 0xBE 0xDF is "工具" in code page 936; Latin1 differs. + const gbk = "D:\\\u00b9\u00a4\u00be\u00df\\app.exe"; + const iconFor = async ( + options: Parameters[0], + ): Promise => + iconDifference(await parse(windowsShortcutBytes(options))); + const ansiLinkInfo = (localBasePath: string) => ({ + localBasePath, + unicode: false, + }); + expect( + await iconFor({ + linkInfo: ansiLinkInfo(gbk), + iconLocation: "D:\\工具\\app.exe", + }), + ).toBeNull(); + // A different comparable target does not outweigh the one that cannot be read. + expect( + await iconFor({ + linkInfo: ansiLinkInfo(gbk), + extraData: [{ kind: "environment", unicode: "C:\\Other\\x.exe" }], + iconLocation: "D:\\工具\\app.exe", + }), + ).toBeNull(); + // An ANSI icon cannot be compared with a Unicode target either. + expect( + await iconFor({ + unicode: false, + linkInfo: { localBasePath: "D:\\工具\\app.exe", unicode: true }, + iconLocation: gbk, + }), + ).toBeNull(); + // Both ANSI: the same code page, so the bytes compare. + for (const [iconLocation, differs] of [ + [gbk, false], + ["D:\\\u00b9\u00a4\u00be\u00df\\other.exe", true], + ] as const) + expect( + await iconFor({ + unicode: false, + linkInfo: { localBasePath: gbk }, + iconLocation, + }), + ).toBe(differs); + // ASCII is the same text in every ANSI code page, so it still compares. + for (const [iconLocation, differs] of [ + ["C:\\Tools\\app.exe", false], + ["C:\\Tools\\other.exe", true], + ] as const) + expect( + await iconFor({ + linkInfo: ansiLinkInfo("C:\\Tools\\app.exe"), + iconLocation, + }), + ).toBe(differs); + // A joined path keeps each part's encoding: an ASCII ANSI share with a + // Unicode suffix, or a Unicode share with an ASCII ANSI suffix, compares. + for (const [linkInfo, iconLocation, differs] of [ + [ + { netName: "\\\\server\\share", commonPathSuffix: "工具\\app.exe" }, + "\\\\server\\share\\工具\\app.exe", + false, + ], + [ + { netName: "\\\\server\\share", commonPathSuffix: "工具\\app.exe" }, + "\\\\server\\share\\工具\\other.exe", + true, + ], + ] as const) + expect( + await iconFor({ + linkInfo: { ...linkInfo, unicode: true, networkUnicode: false }, + iconLocation, + }), + ).toBe(differs); + expect( + await iconFor({ + linkInfo: { + netName: "\\\\server\\共享", + commonPathSuffix: "app.exe", + unicode: false, + networkUnicode: true, + }, + iconLocation: "\\\\server\\共享\\app.exe", + }), + ).toBe(false); +}); + it("compares an icon with a target after collapsing dot segments as text", async () => { const iconFor = async (localBasePath: string, iconLocation: string) => iconDifference( diff --git a/src/native/windows/WindowsShortcutParser.ts b/src/native/windows/WindowsShortcutParser.ts index 6b8c88d9c9..93029f9f01 100644 --- a/src/native/windows/WindowsShortcutParser.ts +++ b/src/native/windows/WindowsShortcutParser.ts @@ -295,66 +295,117 @@ const isFullPath = (path: string): boolean => const hasUnexpandedVariable = (path: string): boolean => /%[^%\\/]+%/u.test(path); +/** + * A recorded path and where its non-ASCII text came from. Every ANSI code page + * agrees with ASCII, so only non-ASCII characters depend on the encoding. A + * non-ASCII ANSI byte is a character from the unobserved system code page, + * which its Latin1 projection does not name. + */ +type RecordedPath = { + readonly path: string; + /** Holds non-ASCII text projected from ANSI bytes. */ + readonly ansi: boolean; + /** Holds non-ASCII UTF-16 text. */ + readonly unicode: boolean; +}; + +const isAscii = (text: string): boolean => + [...text].every((char) => char <= "\u007f"); + +const recorded = (path: string, ansi: boolean): RecordedPath => { + const nonAscii = !isAscii(path); + return { path, ansi: ansi && nonAscii, unicode: !ansi && nonAscii }; +}; + +/** Two paths compare as text only when their non-ASCII parts share one encoding. */ +const comparableText = (left: RecordedPath, right: RecordedPath): boolean => + !((left.ansi || right.ansi) && (left.unicode || right.unicode)); + const iconDiffersFromTarget = ( - icon: string | undefined, - targets: readonly string[], + icon: WindowsShortcut["string_data"]["icon_location"], + targets: readonly RecordedPath[], ): boolean | null => { - if (icon === undefined || !isFullPath(icon) || hasUnexpandedVariable(icon)) - return null; - const location = normalizePath(icon); - const fixedTargets = targets.filter( - (target) => isFullPath(target) && !hasUnexpandedVariable(target), + if (icon === null) return null; + const text = icon.value; + if (!isFullPath(text) || hasUnexpandedVariable(text)) return null; + const iconPath = recorded(text, icon.encoding === "ansi"); + const location = normalizePath(text); + const fullTargets = targets.filter((target) => isFullPath(target.path)); + const fixedTargets = fullTargets.filter( + (target) => + !hasUnexpandedVariable(target.path) && comparableText(target, iconPath), ); - if (fixedTargets.some((target) => normalizePath(target) === location)) + if (fixedTargets.some((target) => normalizePath(target.path) === location)) return false; - // A variable-bearing target could name the icon's file, so no difference is claimed. - if (targets.some((t) => isFullPath(t) && hasUnexpandedVariable(t))) - return null; + // A variable-bearing target, or one whose ANSI bytes the icon text cannot be + // compared with, could name the icon's file, so no difference is claimed. + if (fullTargets.length > fixedTargets.length) return null; return fixedTargets.length === 0 ? null : true; }; +/** LinkInfo local and network base paths, each joined with CommonPathSuffix. */ +const linkInfoTargets = ( + info: NonNullable, +): RecordedPath[] => { + const suffixText = info.common_path_suffix_unicode ?? info.common_path_suffix; + const suffix = + suffixText === null || suffixText.length === 0 + ? null + : recorded(suffixText, info.common_path_suffix_unicode === null); + // Each part keeps its own encoding, so an ASCII ANSI part never hides a + // comparable Unicode part. + const withSuffix = (base: RecordedPath): RecordedPath => + suffix === null + ? base + : { + path: `${base.path.replace(/\\$/u, "")}\\${suffix.path.replace(/^\\/u, "")}`, + ansi: base.ansi || suffix.ansi, + unicode: base.unicode || suffix.unicode, + }; + const values: RecordedPath[] = []; + const local = info.local_base_path_unicode ?? info.local_base_path; + if (local !== null && (suffix !== null || local.length > 0)) + values.push( + withSuffix(recorded(local, info.local_base_path_unicode === null)), + ); + const net = info.common_network_relative_link; + if (net !== null) { + const share = net.net_name_unicode ?? net.net_name; + if (share !== null) + values.push(withSuffix(recorded(share, net.net_name_unicode === null))); + } + return values; +}; + const targetPaths = (report: { readonly link_info: WindowsShortcut["link_info"]; readonly string_data: WindowsShortcut["string_data"]; readonly id_list: WindowsShortcut["id_list"]; readonly extra_data: WindowsShortcut["extra_data"]; -}): string[] => { - const values: string[] = []; - const info = report.link_info; - if (info !== null) { - const local = info.local_base_path_unicode ?? info.local_base_path; - const suffix = info.common_path_suffix_unicode ?? info.common_path_suffix; - if (local !== null && suffix !== null && suffix.length > 0) - values.push( - `${local.replace(/\\$/u, "")}\\${suffix.replace(/^\\/u, "")}`, - ); - else if (local !== null && local.length > 0) values.push(local); - const net = info.common_network_relative_link; - if (net !== null) { - const share = net.net_name_unicode ?? net.net_name; - if (share !== null && suffix !== null && suffix.length > 0) - values.push( - `${share.replace(/\\$/u, "")}\\${suffix.replace(/^\\/u, "")}`, - ); - else if (share !== null) values.push(share); - } - } - if (report.string_data.relative_path !== null) - values.push(report.string_data.relative_path.value); +}): RecordedPath[] => { + const values: RecordedPath[] = + report.link_info === null ? [] : linkInfoTargets(report.link_info); + const relative = report.string_data.relative_path; + if (relative !== null) + values.push(recorded(relative.value, relative.encoding === "ansi")); if (report.id_list !== null) { const lastFile = [...report.id_list.items] .reverse() .find((item) => item.kind === "file_entry"); - if (lastFile?.kind === "file_entry") values.push(lastFile.short_name); + // Type bit 0x04 marks a UTF-16LE name; otherwise the name is ANSI. + if (lastFile?.kind === "file_entry") + values.push( + recorded(lastFile.short_name, (lastFile.type_byte & 0x04) === 0), + ); } // A stored literal target, never expanded: `%VAR%` text stays as written. for (const block of report.extra_data.blocks) { if (block.decoded.kind !== "environment_variable") continue; - const stored = - block.decoded.target_unicode.length > 0 - ? block.decoded.target_unicode - : block.decoded.target_ansi; - if (stored.length > 0) values.push(stored); + const unicode = block.decoded.target_unicode.length > 0; + const stored = unicode + ? block.decoded.target_unicode + : block.decoded.target_ansi; + if (stored.length > 0) values.push(recorded(stored, !unicode)); } return values; }; @@ -389,7 +440,7 @@ const observationsOf = ( { kind: "target_extension", evidence_kind: "derived", - extension: extensionOf(targets[0] ?? null), + extension: extensionOf(targets[0]?.path ?? null), }, { kind: "show_command_minimized", @@ -399,10 +450,7 @@ const observationsOf = ( { kind: "icon_location_differs_from_target", evidence_kind: "derived", - value: iconDiffersFromTarget( - report.string_data.icon_location?.value, - targets, - ), + value: iconDiffersFromTarget(report.string_data.icon_location, targets), }, ]; }; diff --git a/tests/fixtures/binaryDiagnostics/windowsShortcut.ts b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts index b8eb930b83..90eddc6792 100644 --- a/tests/fixtures/binaryDiagnostics/windowsShortcut.ts +++ b/tests/fixtures/binaryDiagnostics/windowsShortcut.ts @@ -67,6 +67,13 @@ export type FixtureLinkInfo = { readonly deviceName?: string; readonly networkProviderType?: number; readonly networkFlags?: number; + /** + * Write LinkInfo's Unicode fields: header 0x24, Unicode volume label and + * network names. Defaults to the link's IsUnicode. + */ + readonly unicode?: boolean; + /** Write Unicode network names; defaults to `unicode`. */ + readonly networkUnicode?: boolean; }; export type FixtureExtraBlock = @@ -278,7 +285,9 @@ const linkInfoBytes = (info: FixtureLinkInfo, unicode: boolean): Buffer => { ? ansiZ(info.localBasePath ?? "C:\\target.exe") : Buffer.alloc(0); const network = - flags & 0x2 ? networkLinkBytes(info, unicode) : Buffer.alloc(0); + flags & 0x2 + ? networkLinkBytes(info, info.networkUnicode ?? unicode) + : Buffer.alloc(0); const suffixAnsi = ansiZ(info.commonPathSuffix ?? ""); const localWide = unicode && flags & 0x1 @@ -512,7 +521,7 @@ export const windowsShortcutBytes = ( if (flags & LINK_FLAG.HasLinkTargetIDList && idList !== null) parts.push(idListBytes(idList)); if (flags & LINK_FLAG.HasLinkInfo && linkInfo !== null) - parts.push(linkInfoBytes(linkInfo, unicode)); + parts.push(linkInfoBytes(linkInfo, linkInfo.unicode ?? unicode)); const strings: Buffer[] = []; if (flags & LINK_FLAG.HasName) strings.push(stringDataField(name, unicode)); if (flags & LINK_FLAG.HasRelativePath)