mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
Merge remote-tracking branch 'origin/main' into fix/full-test-audit-20261110
This commit is contained in:
commit
6377a1831f
11 files changed
+349
-81
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <digit>...` 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<ReturnType<typeof parseBannerText>>;
|
||||
} | 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 <digit>...`
|
||||
* 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<Report["banner"]> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+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