mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
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:
4 files changed
+198
-48
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user