fix(windows): leave a shortcut icon comparison unknown across code pages (#9691)

* fix(windows): leave a shortcut icon comparison unknown across code pages

Windows stores the LinkInfo LocalBasePath only as ANSI when the system
code page can represent it, while StringData stays UTF-16. The parser
projects the ANSI bytes as Latin1, so on a code page 936 host a path such
as F:\百度网盘\BaiduNetdisk\BaiduNetdisk.exe never matched the same
Unicode IconLocation, and icon_location_differs_from_target reported true
for a shortcut whose icon is its own target.

Record whether each target path is an ANSI projection. Compare it with
the icon only when both use the same encoding or the ANSI text is ASCII.
A full target that cannot be compared makes the result null, like a
%VAR% target.

* fix(windows): keep each LinkInfo part's encoding when joining paths

A joined LinkInfo target was marked ANSI when any part was ANSI. An ASCII
ANSI share with a UTF-16 suffix such as \\server\share + 工具\app.exe
then could not be compared with a Unicode icon, although no byte needs a
code page. Record which encoding each part's non-ASCII text came from,
and compare two paths when their non-ASCII text shares one encoding.
This commit is contained in:
RRFZ authored and GitHub committed 2026-11-10 15:39:04 -05:00
1 parent 5e4055b39e
commit b4c3a028fe
4 files changed
+198 -48

No files matched your search

+4 -1
View File
@@ -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
@@ -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<typeof windowsShortcutBytes>[0],
): Promise<boolean | null | undefined> =>
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(
+93 -45
View File
@@ -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<WindowsShortcut["link_info"]>,
): 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),
},
];
};
+11 -2
View File
@@ -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)