diff --git a/docs/guides/native/kernel-build-config.md b/docs/guides/native/kernel-build-config.md index bbc7b5052a..971de720a5 100644 --- a/docs/guides/native/kernel-build-config.md +++ b/docs/guides/native/kernel-build-config.md @@ -32,9 +32,14 @@ otherwise. followed by a version token beginning with a digit and ends at a NUL or newline within 512 bytes. `version` is that token. `builder` is the first parenthesized group (`user@host`). `compiler` is the second group up to the -last `) #`, so nested parentheses in compiler strings are kept. The banner is -`absent` when no such string was found in the searched bytes and `unknown` -when the bytes were not searched. +last `) #`, so nested parentheses in compiler strings are kept. Since Linux +6.1, kbuild also leaves the weak banner of `init/version.o`. It is compiled +before the build number and timestamp exist, so unless `KBUILD_BUILD_VERSION` +and `KBUILD_BUILD_TIMESTAMP` are both set, it has a space right after `#` or at +its end. When a later banner repeats that text up to `#` without that gap, the +later banner is reported; otherwise the first one is. The banner is `absent` +when no such string was found in the searched bytes and `unknown` when the +bytes were not searched. `config` is `present` only when all of the following hold: 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/docs/specs/native/kernel-build-config/spec.md b/docs/specs/native/kernel-build-config/spec.md index 49f8d3523b..5043a06933 100644 --- a/docs/specs/native/kernel-build-config/spec.md +++ b/docs/specs/native/kernel-build-config/spec.md @@ -26,7 +26,10 @@ these files. read through the stable artifact read and never executed. 2. The banner is the first printable `Linux version ` string followed by a version token that starts with a digit and ends at a NUL or newline within - 512 bytes. Version, builder and compiler are split from it. The scan is + 512 bytes. Version, builder and compiler are split from it. A banner with a + space right after `#` or at its end is the kbuild placeholder from + `init/version.o`, built without a build number or timestamp; a later banner + that repeats its text up to `#` without that gap replaces it. The scan is linear in the searched bytes. 3. A config is `present` only when `IKCFG_ST` is followed by a gzip member that ends exactly at the next `IKCFG_ED`, inflates within `max_config_bytes`, diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index c4c536f254..183a5b96e2 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -524,6 +524,9 @@ const projectError = ( RETRYABLE_CODES.has(code) || (error instanceof AnalysisCapabilityUnavailableError && error.retryable) || + (error._tag === "ProcessCaptureError" && + "retryable" in error && + error.retryable === true) || (error instanceof ArtifactOperationError && error.readerLimit?.can_increase === true && !error.cleanupIncomplete) || diff --git a/src/native/elf/KernelBuildConfig.test.ts b/src/native/elf/KernelBuildConfig.test.ts index eb9f93c6ea..66f29834e7 100644 --- a/src/native/elf/KernelBuildConfig.test.ts +++ b/src/native/elf/KernelBuildConfig.test.ts @@ -511,6 +511,31 @@ it("finds a banner after overlapping unterminated repeats in one linear scan", a }); }); +it("reports the link-time banner after the kbuild placeholder banner", async () => { + // Linux 6.1+ keeps init/version.o's weak banner, built before the build + // number and timestamp exist, ahead of the banner the kernel prints. + const head = "Linux version 6.18.0 (u@h) (gcc 13.2.0) #"; + const other = "Linux version 1.0.0 (x@y) (cc) #2"; + const linked = `${head}1 SMP PREEMPT Tue Oct 6 19:13:07 UTC 2026`; + // No build number, then a set KBUILD_BUILD_VERSION with no timestamp. + for (const placeholder of [`${head} SMP PREEMPT `, `${head}1 SMP PREEMPT `]) { + const bytes = Buffer.from( + `${placeholder}\n\0${other}\n\0${placeholder}\n\0${linked}\n`, + ); + expect((await parse(bytes)).banner).toMatchObject({ + text: linked, + version: "6.18.0", + builder: "u@h", + compiler: "gcc 13.2.0", + location: { offset: bytes.length - linked.length - 1 }, + }); + // Without a matching link-time banner the placeholder is still reported. + expect( + (await parse(Buffer.from(`${placeholder}\n\0${other}\n`))).banner, + ).toMatchObject({ text: placeholder, location: { offset: 0 } }); + } +}); + it("honors cancellation during the marker, inflate, and banner scans", async () => { const input = inspectKernelBuildConfigInputSchema.parse({ path: artifactPath, diff --git a/src/native/elf/KernelBuildConfig.ts b/src/native/elf/KernelBuildConfig.ts index 29292a0811..39c81d8f97 100644 --- a/src/native/elf/KernelBuildConfig.ts +++ b/src/native/elf/KernelBuildConfig.ts @@ -556,11 +556,58 @@ const absentBanner = (status: "absent" | "unknown"): Report["banner"] => ({ const isPrintable = (value: number): boolean => value >= 0x20 && value <= 0x7e; +/** + * Text up to and including `#` when the banner's `UTS_VERSION` lacks a build + * number or a timestamp, otherwise null. Since Linux 6.1, kbuild compiles + * `init/version.o` with a temporary `UTS_VERSION` that leaves both empty + * unless `KBUILD_BUILD_VERSION` or `KBUILD_BUILD_TIMESTAMP` is set, so that + * object's weak `linux_banner` has a space right after `#` or at its end. The + * banner the kernel prints comes from `init/version-timestamp.o` in the final + * link: the same text up to `#`, then the build number and timestamp. + */ +const kbuildPlaceholderPrefix = (text: string): string | null => { + const marked = text.lastIndexOf(") #"); + if (marked < 0) return null; + return text.startsWith(" ", marked + 3) || text.endsWith(" ") + ? text.slice(0, marked + 3) + : null; +}; + +/** + * First `Linux version ...` banner in the printable run that ends at + * `end`. Only hits within BANNER_TEXT_BYTES of the terminator fit the window. + */ +const bannerInRun = ( + bytes: Buffer, + at: number, + end: number, +): { + readonly hit: number; + readonly text: string; + readonly parsed: NonNullable>; +} | null => { + const run = bytes.subarray(0, end); + for ( + let hit = run.indexOf(BANNER_PREFIX, Math.max(at, end - BANNER_TEXT_BYTES)); + hit >= 0; + hit = run.indexOf(BANNER_PREFIX, hit + 1) + ) { + const first = bytes[hit + BANNER_PREFIX.length] ?? 0; + if (first < 0x30 || first > 0x39) continue; + const text = bytes.toString("latin1", hit, end); + const parsed = parseBannerText(text); + if (parsed !== null) return { hit, text, parsed }; + } + return null; +}; + /** * First printable, NUL- or newline-terminated `Linux version ...` - * string of at most BANNER_TEXT_BYTES. Every hit inside one printable run - * shares that run's terminator, so each run is scanned once and the search - * resumes after it: the scan is linear in the searched bytes. + * string of at most BANNER_TEXT_BYTES. A kbuild placeholder banner is + * replaced by a later banner that repeats its text up to `#` and is not a + * placeholder itself; without one, the placeholder is reported. Every hit inside one + * printable run shares that run's terminator, so each run is scanned once and + * the search resumes after it: the scan is linear in the searched bytes. */ const findBanner = async ( bytes: Buffer, @@ -569,10 +616,14 @@ const findBanner = async ( ): Promise => { let from = 0; let turnAt = TURN_STRIDE; + let placeholder: { + readonly banner: Report["banner"]; + readonly prefix: string; + } | null = null; for (;;) { assertArtifactReadActive(signal); const at = await findNext(bytes, BANNER_PREFIX, from, signal); - if (at < 0) return absentBanner("absent"); + if (at < 0) return placeholder?.banner ?? absentBanner("absent"); let end = at + BANNER_PREFIX.length; while (end < bytes.length && isPrintable(bytes[end] ?? 0)) { end += 1; @@ -585,31 +636,23 @@ const findBanner = async ( from = end + 1; if (end >= bytes.length || (bytes[end] !== 0 && bytes[end] !== 0x0a)) continue; - // Only hits within BANNER_TEXT_BYTES of the terminator fit the window. - const run = bytes.subarray(0, end); - for ( - let hit = run.indexOf( - BANNER_PREFIX, - Math.max(at, end - BANNER_TEXT_BYTES), - ); - hit >= 0; - hit = run.indexOf(BANNER_PREFIX, hit + 1) - ) { - const first = bytes[hit + BANNER_PREFIX.length] ?? 0; - if (first < 0x30 || first > 0x39) continue; - const text = bytes.toString("latin1", hit, end); - const parsed = parseBannerText(text); - if (parsed === null) continue; - return { - status: "present", - text, - version: parsed.version, - builder: parsed.builder, - compiler: parsed.compiler, - location: located(space, hit, end - hit), - trust: UNTRUSTED, - }; - } + const found = bannerInRun(bytes, at, end); + if (found === null) continue; + const banner: Report["banner"] = { + status: "present", + text: found.text, + version: found.parsed.version, + builder: found.parsed.builder, + compiler: found.parsed.compiler, + location: located(space, found.hit, end - found.hit), + trust: UNTRUSTED, + }; + const prefix = kbuildPlaceholderPrefix(found.text); + if (placeholder === null) { + if (prefix === null) return banner; + placeholder = { banner, prefix }; + } else if (prefix === null && found.text.startsWith(placeholder.prefix)) + return banner; } }; 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/src/process/capture/ProcessCaptureError.test.ts b/src/process/capture/ProcessCaptureError.test.ts index 654cbdcfbc..51791c2211 100644 --- a/src/process/capture/ProcessCaptureError.test.ts +++ b/src/process/capture/ProcessCaptureError.test.ts @@ -76,6 +76,33 @@ it("preserves the compiler output and live cleanup owner at capture normalizatio expect(error.capturedOutput?.exit_code).toBe(7); }); +it("marks a timed-out Darwin compiler inspection as retryable", () => { + const compiler = new DarwinProcessOwnershipInspectionError( + "compiler timed out", + { + compilerOutcome: { + outcome: "timed_out", + code: "ETIMEDOUT", + signal: null, + stderr: "compiler deadline exceeded", + stderrTruncated: false, + outputTruncated: false, + killed: true, + timeoutMs: 5_000, + }, + }, + ); + + const normalized = normalizeCaptureFailure(compiler, undefined); + expect(normalized).toMatchObject({ + retryable: true, + capturedOutput: undefined, + }); + if (!(normalized instanceof ProcessCaptureError)) + throw new Error("Expected normalized capture failure"); + expect(projectAnalysisError(normalized)).toMatchObject({ retryable: true }); +}); + it("retains compiler facts and cleanup dependency through capture finalization", () => { const { cleanup, error } = fixture(); let finalized: unknown; diff --git a/src/process/capture/ProcessCaptureError.ts b/src/process/capture/ProcessCaptureError.ts index 68e0d4c172..d424449c4f 100644 --- a/src/process/capture/ProcessCaptureError.ts +++ b/src/process/capture/ProcessCaptureError.ts @@ -15,6 +15,8 @@ interface ProcessCaptureErrorOptions extends AnalysisErrorOptions { readonly executionFailure?: string; readonly partialObservation?: PartialProcessCaptureObservation; readonly cleanupReport?: ProcessCaptureCleanupReport; + /** Whether the producer identified a transient failure that can be retried. */ + readonly retryable?: boolean; } /** Typed application failure produced by controlled process capture. */ @@ -29,6 +31,11 @@ export class ProcessCaptureError extends AnalysisError { | PartialProcessCaptureObservation | undefined; override readonly cleanupReport: ProcessCaptureCleanupReport | undefined; + readonly #retryable: boolean; + + get retryable(): boolean { + return this.#retryable && !this.cleanupIncomplete; + } constructor(message: string, options?: ProcessCaptureErrorOptions) { super(message, options); @@ -41,6 +48,7 @@ export class ProcessCaptureError extends AnalysisError { this.executionFailure = options?.executionFailure ?? message; this.partialObservation = options?.partialObservation; this.cleanupReport = options?.cleanupReport; + this.#retryable = options?.retryable === true; } /** Add final capture observations while preserving primary facts and cleanup ownership. */ @@ -82,6 +90,7 @@ export class ProcessCaptureError extends AnalysisError { ...(executionFailure === undefined ? {} : { executionFailure }), ...(partialObservation === undefined ? {} : { partialObservation }), ...(cleanupReport === undefined ? {} : { cleanupReport }), + ...(this.#retryable ? { retryable: true } : {}), }).bindCleanupDependencies([ this, ...(secondary === undefined ? [] : [secondary]), @@ -135,6 +144,9 @@ export const normalizeCaptureFailure = ( ...(cause.capturedOutput === undefined ? {} : { capturedOutput: cause.capturedOutput }), + ...(cause.compilerOutcome?.outcome === "timed_out" + ? { retryable: true } + : {}), }); return cause.cleanupFailure === undefined ? normalized 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)