From 3ded799bc7b9a588404913b4064aff8c1412a045 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:12:36 -0700 Subject: [PATCH 01/21] fix(reference): never resolve a directory to an extensionless index (#3789) resolveInternalSpecifier offered posix.join(dir, "index") as the second candidate, ahead of every extension. With both dep.js and an extensionless dep/index present, require("./dep") therefore resolved to dep/index, a file that neither Node nor TypeScript ever selects. Directory candidates now carry an extension. File and index ordering is otherwise unchanged, so Node-validated cases such as dep/index.js winning over dep.cjs keep their meaning. Fixes #3381 Co-authored-by: Claude Opus 5.5 --- src/application/ReferenceSourceImportEntries.ts | 4 +++- .../boundary/cli/referenceSourceDirectoryImports.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index d2602df044..c53df6a9d1 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -110,9 +110,11 @@ const resolveInternalSpecifier = ( const directoryReference = specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier)); const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; + // A directory resolves only to an index with an extension; neither Node nor + // TypeScript selects an extensionless `index` file. const candidates = suffixes.flatMap((suffix) => [ ...(directoryReference ? [] : [`${normalized}${suffix}`]), - posix.join(normalized, `index${suffix}`), + ...(suffix === "" ? [] : [posix.join(normalized, `index${suffix}`)]), ]); // Retain present runtime files first; substitute only a missing counterpart. // Counterparts cover explicit filenames, not directory/index resolution. diff --git a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts index 6270855cbc..8fbd8a421c 100644 --- a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts +++ b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts @@ -29,11 +29,13 @@ describe.skipIf(process.platform === "win32")( ["direct.cjs", "./lib.js", "lib.js"], ["fallback.cjs", "./fallback", "fallback/index.js"], ["legacy-order.cjs", "./dep", "dep/index.js"], + ["bare-index.cjs", "./bare", "bare.js"], ] as const; const unresolved = [ ["missing.cjs", "./missing/", "missing/"], ["escape.cjs", "../outside/", "../outside/"], ["main.ts", "./folder.js/.", "folder.js"], + ["only-bare.cjs", "./only/", "only/"], ] as const; const files = new Map([ ["index.js", 'module.exports = "root index";\n'], @@ -48,6 +50,9 @@ describe.skipIf(process.platform === "win32")( 'export const value = "emitted filename counterpart";\n', ], ["missing.js", 'module.exports = "not a directory";\n'], + ["bare.js", 'module.exports = "file before directory";\n'], + ["bare/index", 'module.exports = "extensionless index";\n'], + ["only/index", 'module.exports = "extensionless index";\n'], ...[...imports, ...unresolved].map( ([path, specifier]) => [path, `require(${JSON.stringify(specifier)});\n`] as const, @@ -75,6 +80,9 @@ describe.skipIf(process.platform === "win32")( expect(() => createRequire(join(root, "main.ts")).resolve("./folder.js/."), ).toThrow(); + expect(() => + createRequire(join(root, "only-bare.cjs")).resolve("./only/"), + ).toThrow(); expect( createRequire(join(root, "escape.cjs")).resolve("../outside/"), ).toBe(outside); From 90c69229050cd251f86294a6f3ff5e22abf6912f Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:12:49 -0700 Subject: [PATCH 02/21] fix(javascript): keep empty URL path segments significant (#3792) * fix(javascript): keep empty URL path segments significant ESM imports and HTML script references were joined with POSIX path rules, which collapse '//' before applying '..'. URL resolution removes the empty segment instead, so './assets//../app.js' names assets/app.js, not app.js, and the graph linked the importer and renderer to the wrong file. URL references refused every '//' even when dot segments cancel it. Resolve ESM, HTML (including document base) and URL references through one URL dot-segment helper. A remaining empty segment stays unavailable, references above the artifact root are still rejected, and CommonJS keeps filesystem path semantics. Fixes #3307 Co-Authored-By: Claude Opus 5.5 * fix(javascript): resolve HTML base and reference as one URL path The base href was turned into a canonical path before the script reference was applied. A base empty segment that the reference's '..' would remove was rejected early, and an above-root '..' from the base became a removable segment that the reference's '..' could cancel. Keep one URL path state through base and reference resolution, with above-root escapes held apart from removable segments, and reject empty segments only in the final path. Remove the superseded htmlBaseDirectory. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 4 + .../JavaScriptArtifactPathResolution.test.ts | 51 ++++++- .../JavaScriptArtifactPathResolution.ts | 129 ++++++++++++++---- .../JavaScriptHtmlBaseResolution.test.ts | 21 +++ 4 files changed, 176 insertions(+), 29 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 18175ef8c9..07548202e9 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -629,6 +629,10 @@ href are read without surrounding whitespace or embedded tabs and newlines, and their percent-encoded path bytes are decoded; encoded dot and separator bytes are still rejected. CommonJS module lookups retain extension and directory resolution. +ESM, HTML, and URL references apply URL dot segments to empty path segments: +`./assets//../app.js` selects `assets/app.js`, while a remaining empty segment +such as `./assets//app.js` stays unavailable. CommonJS keeps filesystem path +semantics and selects `app.js` for the same text. Unresolved HTML references retain their declaration, source range, and resolution reason in the renderer observations. HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 77a448b4a3..dc0ccf5e61 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -2495,20 +2495,61 @@ describe("url-reference directory and empty-segment semantics", () => { }); }); - it.each(["./nested//worker.js", "./nested//../worker.js"])( - "does not collapse empty URL path segments: %s", - (declaredPath) => { + it.each([ + ["./nested//worker.js", null], + ["./nested//../worker.js", "dist/nested/worker.js"], + ])( + "keeps empty URL path segments significant: %s", + (declaredPath, resolvedPath) => { + // A dot segment removes the preceding empty segment, never `nested`. + expect(new URL(declaredPath, "file:///root/dist/app.mjs").pathname).toBe( + resolvedPath === null + ? "/root/dist/nested//worker.js" + : `/root/${resolvedPath}`, + ); + for (const context of ["url-reference", "html-reference"] as const) + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context, + files: fileMap([ + file("dist/worker.js", "root"), + file("dist/nested/worker.js", "root"), + ]), + }), + context, + ).toMatchObject({ resolved_path: resolvedPath }); expect( resolve({ declaredPath, sourcePath: "dist/app.mjs", - context: "url-reference", + context: "module-specifier", + moduleKind: "import", files: fileMap([ file("dist/worker.js", "root"), file("dist/nested/worker.js", "root"), ]), }), - ).toMatchObject({ resolved_path: null }); + ).toMatchObject({ resolved_path: resolvedPath }); + // CommonJS keeps filesystem path semantics. + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file("dist/worker.js", "root"), + file("dist/nested/worker.js", "root"), + ]), + }), + ).toMatchObject({ + resolved_path: + declaredPath === "./nested//worker.js" + ? "dist/nested/worker.js" + : "dist/worker.js", + }); }, ); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 7a3e4c89e0..94ffbc7725 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -551,22 +551,24 @@ const contextualCandidate = ( return unresolvedCandidate("rejected", [ "The URL reference path percent-decodes to NUL or to bytes that are not UTF-8.", ]); - // Empty URL segments are significant; canonical filesystem paths cannot - // preserve them, including their interaction with following dot segments. - if (decoded.includes("//")) - return unresolvedCandidate("unavailable", [ - "Repeated URL path separators cannot be mapped to an exact canonical artifact file.", - ]); - return exactUrlPathCandidate( + const candidate = urlRelativeCandidate( + posix.dirname(input.sourcePath), decoded, - relativeCandidate(input.sourcePath, decoded), - "URL", ); + return typeof candidate === "string" + ? exactUrlPathCandidate(decoded, candidate, "URL") + : candidate; } - const candidate = relativeCandidate(input.sourcePath, declared); - return context === "module-specifier" && input.moduleKind === "import" - ? exactUrlPathCandidate(declared, candidate, "module URL") - : candidate; + if (context === "module-specifier" && input.moduleKind === "import") { + const candidate = urlRelativeCandidate( + posix.dirname(input.sourcePath), + declared, + ); + return typeof candidate === "string" + ? exactUrlPathCandidate(declared, candidate, "module URL") + : candidate; + } + return relativeCandidate(input.sourcePath, declared); }; const decodeModuleUrlPath = ( @@ -593,6 +595,78 @@ const relativeCandidate = (sourcePath: string, declared: string): string => ? declared.slice(1) : posix.join(posix.dirname(sourcePath), declared); +/** + * A URL path during reference resolution. Above-root `..` markers are kept + * apart from segments, so a later `..` cannot cancel an escape, while empty + * segments stay removable until the final path is formed. + */ +interface UrlPathState { + readonly escapes: number; + readonly segments: readonly string[]; +} + +const urlDirectoryState = (directory: string): UrlPathState => ({ + escapes: 0, + segments: directory === "" || directory === "." ? [] : directory.split("/"), +}); + +/** + * Apply URL dot segments: `..` removes the previous segment even when it is + * empty, so `assets//../app.js` names `assets/app.js`. A final `.` or `..` + * leaves directory form, as the URL parser does. + */ +const applyUrlReference = ( + base: UrlPathState, + reference: string, +): UrlPathState => { + const absolute = reference.startsWith("/"); + const segments = absolute ? [] : [...base.segments]; + let escapes = absolute ? 0 : base.escapes; + const parts = (absolute ? reference.slice(1) : reference).split("/"); + for (const [index, part] of parts.entries()) { + if (part !== "." && part !== "..") { + segments.push(part); + continue; + } + if (part === "..") + if (segments.length > 0) segments.pop(); + else escapes += 1; + if (index === parts.length - 1) segments.push(""); + } + return { escapes, segments }; +}; + +/** A relative reference replaces the base URL's final path segment. */ +const urlBaseDirectory = (base: UrlPathState): UrlPathState => ({ + escapes: base.escapes, + segments: base.segments.slice(0, -1), +}); + +/** + * Form the artifact candidate. A remaining empty segment cannot name a + * canonical artifact file; a trailing one is directory form. Confinement + * rejects a candidate that keeps an above-root marker. + */ +const urlPathCandidate = (path: UrlPathState): string | CandidateResolution => { + const segments = + path.segments.at(-1) === "" ? path.segments.slice(0, -1) : path.segments; + if (segments.includes("")) + return unresolvedCandidate("unavailable", [ + "Repeated URL path separators cannot be mapped to an exact canonical artifact file.", + ]); + return [ + ...Array.from({ length: path.escapes }, () => ".."), + ...segments, + ].join("/"); +}; + +/** Resolve a URL path reference against a canonical directory. */ +const urlRelativeCandidate = ( + directory: string, + reference: string, +): string | CandidateResolution => + urlPathCandidate(applyUrlReference(urlDirectoryState(directory), reference)); + const bareModuleCandidate = ( input: ResolveArtifactPathInput, ): CandidateResolution => { @@ -1066,11 +1140,12 @@ const htmlCandidate = ( return unresolvedCandidate("rejected", [ "The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.", ]); - if (declaredPath.startsWith("/")) return declaredPath.slice(1); + if (declaredPath.startsWith("/")) + return urlRelativeCandidate("", declaredPath); if (base === undefined || base === null || base === "") return declaredPath === "" ? input.sourcePath - : posix.join(posix.dirname(input.sourcePath), declaredPath); + : urlRelativeCandidate(posix.dirname(input.sourcePath), declaredPath); // A local base href is a second untrusted path input; apply the same // admission rules a declared path gets so it cannot smuggle traversal or // separator syntax past canonicalization. @@ -1083,12 +1158,21 @@ const htmlCandidate = ( return unresolvedCandidate("rejected", [ "The document base href path percent-decodes to NUL or to bytes that are not UTF-8.", ]); - const basePath = decodedBase.startsWith("/") - ? decodedBase.slice(1) - : posix.join(posix.dirname(input.sourcePath), decodedBase); - if (declaredPath === "") - return exactUrlPathCandidate(decodedBase, basePath, "HTML"); - return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath); + // Keep the base's empty segments and escapes until the reference is + // applied; only the final path must name a canonical artifact file. + const baseState = applyUrlReference( + urlDirectoryState(posix.dirname(input.sourcePath)), + decodedBase, + ); + if (declaredPath === "") { + const basePath = urlPathCandidate(baseState); + return typeof basePath === "string" + ? exactUrlPathCandidate(decodedBase, basePath, "HTML") + : basePath; + } + return urlPathCandidate( + applyUrlReference(urlBaseDirectory(baseState), declaredPath), + ); }; const exactUrlPathCandidate = ( @@ -1112,9 +1196,6 @@ const urlPathIsDirectory = (path: string): boolean => path === "." || path === ".."; -const htmlBaseDirectory = (base: string, basePath: string): string => - urlPathIsDirectory(base) ? basePath : posix.dirname(basePath); - const confineCandidate = (candidate: string): string | CandidateResolution => { const normalized = normalizeJoinedLogicalPath(candidate); if (logicalPathEscapesRoot(normalized)) diff --git a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts index 1da6539a03..1a4bbd8503 100644 --- a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts +++ b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts @@ -6,6 +6,7 @@ const paths = [ "assets/app.js", "renderer/app.js", "renderer/a/app.js", + "renderer/assets/app.js", "app.js", ]; const files = new Map( @@ -105,6 +106,26 @@ describe("HTML base href dot segments", () => { }, ); }); +describe("HTML base href empty segments", () => { + it("lets the script reference remove an empty segment from the base", () => { + // URL resolution keeps the base's empty segment until `..` removes it. + expect( + new URL("../app.js", new URL("./assets//", documentUrl)).pathname, + ).toBe("/renderer/assets/app.js"); + expect(resolve("../app.js", "./assets//")).toMatchObject({ + resolution_status: "resolved", + resolved_path: "renderer/assets/app.js", + }); + }); + it("keeps an above-root base escape when the reference also climbs", () => { + expect(resolve("../app.js", "../../")).toMatchObject({ + resolution_status: "rejected", + limitations: [ + "The resolved candidate escapes the canonical artifact root.", + ], + }); + }); +}); describe("HTML base href path syntax", () => { it.each(["%2e%2e/secret/", "/a/%2e%2e/", "/a\\b/", "a\0b"])( "rejects base href %j with the same admission rules a declared path gets", From 7a66470385636e57191f964ca584484e4bd01d34 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:13:01 -0700 Subject: [PATCH 03/21] fix(apple): report plist dates the decoder cannot represent as unknown (#3793) plutil writes years past 9999 and negative years in a form the pinned XML decoder cannot parse, and binary plists can hold dates outside the JavaScript Date range. The decoder returns an Invalid Date. Its projection's toISOString() call then threw, so inspect_plist failed the whole plist as "unreadable_output" with a `rea doctor` remediation. Keyed archive inspection and Interface Builder decoding rejected the archive in the same way. projectPlistValue, the shared projection, now reports such a date as { "$plist_type": "date", "iso8601": null } and counts it. inspect_plist, keyed archive inspection and Interface Builder decoding each add a counted limitation, as they already do for non-finite reals. Co-authored-by: Claude Opus 5.5 --- docs/native-investigation.md | 6 +++ .../apple/InterfaceBuilderAnalysis.ts | 33 ++++++++++--- src/artifacts/apple/KeyedArchiveReader.ts | 16 +++++-- src/domain/apple/plistValue.test.ts | 14 ++++++ src/domain/apple/plistValue.ts | 17 +++++-- src/native/parsers/plist.ts | 4 ++ tests/boundary/cli/nativePlistCli.test.ts | 4 ++ .../filesystem/interfaceBuilderBundle.test.ts | 27 +++++++++++ .../filesystem/keyedArchiveDateRange.test.ts | 48 +++++++++++++++++++ 9 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 tests/boundary/filesystem/keyedArchiveDateRange.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index b9b42dcb8b..ecda41a465 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -570,6 +570,12 @@ Exact and ambiguous value counts describe emitted root/object value instances; a root and an object may contain the same value. Archive digests identify the original bytes. +Dates are reported as `{ "$plist_type": "date", "iso8601": "" }`. A +date the decoder cannot represent, such as an XML date with a year beyond 9999 +or a negative year, is reported as `{ "$plist_type": "date", "iso8601": null }` +with a counted limitation; the rest of the plist or archive is still decoded. +`inspect_plist` and Interface Builder decoding use the same projection. + ## Locate a byte signature Use `search_bytes` after opening a target with Ghidra to locate a known constant diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 00db7f03c7..32760e0820 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -7,7 +7,10 @@ import { isUIKitRuntimeConnectionClass, } from "../../domain/apple/interfaceBuilderKeyedArchive.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; -import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { + projectPlistValue, + unknownDateLimitation, +} from "../../domain/apple/plistValue.js"; import { omittedPrototypeKeysLimitation, parseXmlPropertyList, @@ -58,6 +61,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const incompleteHierarchies = new Map(); const unresolvedConnections = new Map(); const prototypeKeyOmissions = new Map(); + const unknownDates = new Map(); let omitted = 0; let attempted = 0; let aggregateInputBytes = 0; @@ -131,16 +135,21 @@ export const analyzeInterfaceBuilderBundle = async (input: { binaryPrototypeKeyObserved = decodedReservation.binaryPrototypeKeyObserved; } - const { value: raw, omittedPrototypeKeys } = - nib === null - ? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved) - : { value: nib.raw, omittedPrototypeKeys: 0 }; + const { + value: raw, + omittedPrototypeKeys, + unknownDateCount, + } = nib === null + ? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved) + : { value: nib.raw, omittedPrototypeKeys: 0, unknownDateCount: 0 }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); if (nib !== null && nib.unresolvedConnections > 0) unresolvedConnections.set(entry.path, nib.unresolvedConnections); if (omittedPrototypeKeys !== 0) prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); + if (unknownDateCount !== 0) + unknownDates.set(entry.path, unknownDateCount); const documentHash = createHash("sha256").update(bytes).digest("hex"); documents.push({ relativePath: entry.path, @@ -178,6 +187,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { incompleteHierarchies, unresolvedConnections, prototypeKeyOmissions, + unknownDates, invalid, omittedArchives, unreportedOmittedArchives, @@ -246,6 +256,7 @@ const finalizeHierarchyCoverage = ( incompleteHierarchies: ReadonlyMap; unresolvedConnections: ReadonlyMap; prototypeKeyOmissions: ReadonlyMap; + unknownDates: ReadonlyMap; invalid: readonly string[]; omittedArchives: readonly { path: string; reason: string }[]; unreportedOmittedArchives: number; @@ -257,6 +268,7 @@ const finalizeHierarchyCoverage = ( incompleteHierarchies, unresolvedConnections, prototypeKeyOmissions, + unknownDates, invalid, omittedArchives, unreportedOmittedArchives, @@ -342,6 +354,9 @@ const finalizeHierarchyCoverage = ( ...[...prototypeKeyOmissions].map( ([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`, ), + ...[...unknownDates].map( + ([path, count]) => `${path}: ${unknownDateLimitation(count)}`, + ), ...omittedArchives.map( ({ path, reason }) => `${path}: omitted because ${reason.replaceAll("_", " ")}.`, @@ -693,6 +708,7 @@ const decodePlist = ( ): { readonly value: JsonValue; readonly omittedPrototypeKeys: number | null; + readonly unknownDateCount: number; } => { const binary = hasBinaryPlistSignature(bytes); const { value, omittedPrototypeKeys } = binary @@ -701,5 +717,10 @@ const decodePlist = ( omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0, } : parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes)); - return { value: projectPlistValue(value).value, omittedPrototypeKeys }; + const projected = projectPlistValue(value); + return { + value: projected.value, + omittedPrototypeKeys, + unknownDateCount: projected.unknownDateCount, + }; }; diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 7bd64511e4..ff1bdf99cd 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -6,7 +6,10 @@ import { AnalysisInputError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; -import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { + projectPlistValue, + unknownDateLimitation, +} from "../../domain/apple/plistValue.js"; import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; import { binaryPlistNumberLiterals, @@ -75,9 +78,11 @@ export const decodeKeyedArchiveBytes = ( : 0, } : parseXmlPropertyList(xmlText ?? ""); - const { value: plistValue, unknownRealCount } = projectPlistValue( - parsed.value, - ); + const { + value: plistValue, + unknownRealCount, + unknownDateCount, + } = projectPlistValue(parsed.value); const graph = projectKeyedArchive(plistValue, selection); const numbers = createPlistNumberProjection( binary @@ -112,6 +117,9 @@ export const decodeKeyedArchiveBytes = ( : [ `${String(unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because JSON evidence cannot preserve NaN vs infinity.`, ]), + ...(unknownDateCount === 0 + ? [] + : [unknownDateLimitation(unknownDateCount)]), ], }; }; diff --git a/src/domain/apple/plistValue.test.ts b/src/domain/apple/plistValue.test.ts index 4a3465a15b..65ce87ba92 100644 --- a/src/domain/apple/plistValue.test.ts +++ b/src/domain/apple/plistValue.test.ts @@ -2,6 +2,20 @@ import { expect, it } from "vitest"; import { projectPlistValue } from "./plistValue.js"; +// Binary decoders build dates from seconds; beyond the Date range they are invalid. +it("projects an unrepresentable date as unknown and counts it", () => { + expect( + projectPlistValue({ far: new Date(Number.NaN), near: new Date(0) }), + ).toEqual({ + value: { + far: { $plist_type: "date", iso8601: null }, + near: { $plist_type: "date", iso8601: "1970-01-01T00:00:00.000Z" }, + }, + unknownRealCount: 0, + unknownDateCount: 1, + }); +}); + it("rejects excessive plist nesting without exhausting the stack", () => { const deep: unknown[] = []; let cursor = deep; diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index 418688dd72..3ea0c87331 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -14,8 +14,14 @@ export interface ProjectedPlistValue { readonly value: JsonValue; /** Reals decoded as NaN, which the decoder also uses for ±infinity. */ readonly unknownRealCount: number; + /** Dates the decoder could not represent, reported without a timestamp. */ + readonly unknownDateCount: number; } +/** Limitation for results whose plist held dates the decoder could not represent. */ +export const unknownDateLimitation = (count: number): string => + `${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent them, such as XML dates with years beyond 9999 or negative years, or dates outside the JavaScript date range.`; + /** * Project a decoded plist into JSON. Data and dates become explicit * `$plist_type` objects; a non-finite real becomes an explicit unknown @@ -23,6 +29,7 @@ export interface ProjectedPlistValue { */ export const projectPlistValue = (value: unknown): ProjectedPlistValue => { let unknownRealCount = 0; + let unknownDateCount = 0; const project = (item: unknown, depth: number): JsonValue => { if (depth > MAX_PLIST_DEPTH) throw new RangeError( @@ -33,8 +40,12 @@ export const projectPlistValue = (value: unknown): ProjectedPlistValue => { $plist_type: "data", base64: Buffer.from(item).toString("base64"), }; - if (item instanceof Date) - return { $plist_type: "date", iso8601: item.toISOString() }; + if (item instanceof Date) { + if (!Number.isNaN(item.getTime())) + return { $plist_type: "date", iso8601: item.toISOString() }; + unknownDateCount += 1; + return { $plist_type: "date", iso8601: null }; + } if (typeof item === "number" && !Number.isFinite(item)) { unknownRealCount += 1; return { $plist_type: "real", value: null }; @@ -51,5 +62,5 @@ export const projectPlistValue = (value: unknown): ProjectedPlistValue => { return z.union([z.string(), z.number(), z.boolean(), z.null()]).parse(item); }; const projected = project(value, 0); - return { value: projected, unknownRealCount }; + return { value: projected, unknownRealCount, unknownDateCount }; }; diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index b27f67b1ee..a3b6c4fa5e 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -4,6 +4,7 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { projectPlistValue, + unknownDateLimitation, type ProjectedPlistValue, } from "../../domain/apple/plistValue.js"; import { @@ -178,6 +179,9 @@ export const parsePlistXml = ( : [ `${String(projected.unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because the XML decoder does not distinguish NaN from infinity.`, ]), + ...(projected.unknownDateCount === 0 + ? [] + : [unknownDateLimitation(projected.unknownDateCount)]), ...numberLimitations(exactIntegerCount, ambiguousNumberCount), ...prototypeKeyLimitations(omittedPrototypeKeys), ], diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index 324d4e20c5..62ec9ce985 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -96,6 +96,8 @@ describe.skipIf(process.platform !== "darwin")( "CFBundleIdentifiercom.example.types", "BlobAAEC", "Built2020-01-01T00:00:00Z", + // plutil writes years past 9999 unsigned, which the decoder rejects. + "Far11507-08-17T05:20:00Z", "Rationan", "Nested", "Ratio0.5", @@ -130,6 +132,7 @@ describe.skipIf(process.platform !== "darwin")( $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z", }, + Far: { $plist_type: "date", iso8601: null }, Ratio: { $plist_type: "real", value: null }, Nested: [ { @@ -148,6 +151,7 @@ describe.skipIf(process.platform !== "darwin")( limitations: [ expect.stringContaining("XML conversion"), expect.stringContaining("2 non-finite real"), + expect.stringContaining("1 date value(s)"), ], }, }); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 543248e635..1b2954b7b6 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -961,6 +961,33 @@ describe("bounded Interface Builder archive decoding", () => { ); }); + it("decodes an archive whose date the decoder cannot represent", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + // plutil writes years past 9999 unsigned, which the decoder rejects. + await writeFile( + join(resources, "Far.nib"), + '$archiverNSKeyedArchiver$objects$null11507-08-17T05:20:00Z$top', + ); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "f".repeat(64), + }); + + expect(result.documents).toHaveLength(1); + expect(result.limitations).toContainEqual( + expect.stringMatching( + /^Contents\/Resources\/Far\.nib: 1 date value\(s\)/u, + ), + ); + expect(result.limitations).not.toContainEqual( + expect.stringContaining("could not be decoded"), + ); + }); + it.skipIf(!xcodeIbtoolAvailable)( "decodes an Xcode-compiled storyboard NIB and recovers its UI routes", async () => { diff --git a/tests/boundary/filesystem/keyedArchiveDateRange.test.ts b/tests/boundary/filesystem/keyedArchiveDateRange.test.ts new file mode 100644 index 0000000000..9a45bbaa61 --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveDateRange.test.ts @@ -0,0 +1,48 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build } from "plist"; +import { expect } from "vitest"; + +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "built CLI reports a date the decoder cannot represent as unknown", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-archive-date-"); + const path = join(root, "archive.plist"); + // plutil writes years past 9999 unsigned, which the decoder rejects. + const xml = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "__FAR__", new Date("2020-01-01T00:00:00Z")], + $top: { far: { CF$UID: 1 }, near: { CF$UID: 2 } }, + }).replace( + "__FAR__", + "11507-08-17T05:20:00Z", + ); + await writeFile(path, xml); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode).toBe(0); + const result = keyedArchiveResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.objects.map(({ id, value }) => ({ id, value }))).toEqual([ + { id: 0, value: "$null" }, + { id: 1, value: { $plist_type: "date", iso8601: null } }, + { + id: 2, + value: { $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z" }, + }, + ]); + expect(result.limitations).toContainEqual( + expect.stringContaining("1 date value(s)"), + ); + }, +); From af6a9980669129aae882c9216766fc8d9f544079 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:13:17 -0700 Subject: [PATCH 04/21] fix(javascript): keep string Worker paths unresolved without a document base (#3797) A plain new Worker("./w.js") or navigator.serviceWorker.register("./sw.js") resolves against the document base URL in a browser, and a Node worker_threads string resolves against the working directory. Static analysis was resolving browser strings against the calling script, which recorded a resolved mapping to the wrong file. Only new Worker(new URL(path, import.meta.url)) now produces a specifier. Other worker and service-worker strings keep their expression and stay unresolved, matching the existing worker_threads string handling. Fixes #3309 Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 5 +- ...scriptStaticAnalysisCalleeBoundary.test.ts | 5 +- .../javascriptStaticAnalysisCalls.ts | 18 ++++-- .../applicationWorkflowCli.test.ts | 54 ++++++++++------- .../javascriptWorkerUrlCli.test.ts | 60 ++++++++++++++++--- ...javascriptApplicationEmptyLiterals.test.ts | 4 +- ...ascriptApplicationTemplateLiterals.test.ts | 2 +- .../javascriptSourceMapLocations.test.ts | 20 +++---- .../javascriptRuntimeReconciliation.ts | 2 +- 9 files changed, 119 insertions(+), 51 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 07548202e9..e66d52d724 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -223,7 +223,10 @@ Selected bounded text is then parsed as inert data to recover: - Webpack and Rspack chunk registrations and module factories represented as AST literals; - static imports, dynamic imports, CommonJS `require` calls, workers, and - service workers; + service workers (only `new Worker(new URL(path, import.meta.url))` resolves + relative to the calling module; string worker and service-worker paths use + the document base URL or Node's working directory, which static analysis + does not establish, so they keep their expression and stay unresolved); - route, network endpoint, storage, and vendor-marker observations; - local source-map declarations and original source names and content digests; diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index 477bef1ac4..cb602ca0f1 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -27,11 +27,12 @@ describe("static callee boundary matching", () => { ["./bare.html", "call:loadFile"], ["https://optional.example.test", "call:win.loadURL"], ]); + // The document base URL is unknown, so the script path stays unresolved. expect( analysis.references .filter(({ kind }) => kind === "service-worker") - .map(({ specifier }) => specifier), - ).toEqual(["./worker.js"]); + .map(({ specifier, expression }) => [specifier, expression]), + ).toEqual([[null, 'navigator.serviceWorker.register("./worker.js")']]); expect( analysis.endpoints .filter(({ mechanism }) => mechanism.endsWith("loadURL")) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 816ba3eaf2..16a123b8b5 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -95,19 +95,25 @@ export const inspectCall = ( addReference(context, { node, kind: "worker", - // Node string paths use the process cwd or filesystem root, neither of - // which is established by the analyzed source's artifact location. + // A string path is relative to the document base URL in a browser and + // to the process cwd in Node; the analyzed source's artifact location + // establishes neither. Only `new URL(path, import.meta.url)` names a + // location relative to this module. specifier: - (workerConstructor === "node" ? undefined : first) ?? - (workerConstructor === undefined || worker === undefined + workerConstructor === undefined || worker === undefined ? undefined - : workerUrlSpecifier(worker, bindings)), + : workerUrlSpecifier(worker, bindings), }); + // Service worker script URLs are also relative to the document base URL. if ( name === "serviceWorker.register" || name.endsWith(".serviceWorker.register") ) - addReference(context, { node, kind: "service-worker", specifier: first }); + addReference(context, { + node, + kind: "service-worker", + specifier: undefined, + }); inspectEndpointCall(node, name, first, context); inspectStorageCall(node, name, first, context); if (first === "audio" || first === "video") diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index eefa8a7f95..da514f2921 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -541,7 +541,7 @@ it("resolves source-map URL paths once without changing their declarations", asy await writeFile( join(root, "app.js"), cases.map(([url]) => `//# sourceMappingURL=${url}\n`).join("") + - 'new Worker("./worker%20file.js");\n' + + 'new Worker(new URL("./worker%20file.js", import.meta.url));\n' + 'navigator.serviceWorker.register("./service%20worker.js");\n', ); await writeFile(join(root, "worker file.js"), "export const worker = 1;\n"); @@ -598,27 +598,41 @@ it("resolves source-map URL paths once without changing their declarations", asy }), ); } - for (const [kind, specifier, path] of [ - ["worker", "./worker%20file.js", "worker file.js"], - ["service-worker", "./service%20worker.js", "service worker.js"], - ]) { - const worker = graph.nodes.find((node) => node.kind === kind); - expect(worker?.observations).toContainEqual( - expect.objectContaining({ - properties: expect.objectContaining({ - declared_specifier: specifier, - resolved_path: path, - }), + const worker = graph.nodes.find((node) => node.kind === "worker"); + expect(worker?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: "./worker%20file.js", + resolved_path: "worker file.js", }), - ); - expect(graph.edges).toContainEqual( - expect.objectContaining({ - source_node_id: worker?.node_id, - relation: "maps_to", - properties: { resolved_path: path }, + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: worker?.node_id, + relation: "maps_to", + properties: { resolved_path: "worker file.js" }, + }), + ); + // A service worker script URL is relative to the unestablished document base. + const serviceWorker = graph.nodes.find( + (node) => node.kind === "service-worker", + ); + expect(serviceWorker?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: null, + expression: 'navigator.serviceWorker.register("./service%20worker.js")', + resolved_path: null, }), - ); - } + }), + ); + expect( + graph.edges.some( + ({ source_node_id, relation }) => + source_node_id === serviceWorker?.node_id && relation === "maps_to", + ), + ).toBe(false); expect((await analyzeThroughStdioMcp(root)).graph).toEqual(graph); }, 20_000); diff --git a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts index 977083c760..1039f5d3a1 100644 --- a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts +++ b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts @@ -279,12 +279,6 @@ cliTest( specifier: "./worker.js", resolved: "bundled/worker.js", }, - { - source: "nested/literal.mjs", - expression: 'new Worker("./literal-worker.js");', - specifier: "./literal-worker.js", - resolved: "nested/literal-worker.js", - }, ]; await writeFixtureFiles(root, { ...Object.fromEntries( @@ -431,9 +425,6 @@ cliTest( "directory-dot.mjs": "./worker.js/.", "directory-parent.mjs": "./worker.js/child/..", "empty-segment.mjs": "./child//../worker.js", - "empty-worker-url.mjs": "", - "query-only-worker-url.mjs": "?cache=1", - "fragment-only-worker-url.mjs": "#worker", "package.mjs": "./package-worker", "external.mjs": "https://example.test/worker.js", }; @@ -478,6 +469,57 @@ cliTest( }, ); +cliTest( + "keeps string Worker paths unresolved because the document base is unknown", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-document-base-"); + // A browser resolves these strings against the document base URL, not the + // calling script; Node resolves them against the working directory. + const expressions = { + "scripts/main.js": 'new Worker("./worker.js")', + "scripts/service.js": 'navigator.serviceWorker.register("./sw.js")', + }; + await writeFixtureFiles(root, { + "index.html": '', + ...Object.fromEntries( + Object.entries(expressions).map(([path, code]) => [path, `${code};`]), + ), + "worker.js": "self.postMessage('document-relative worker');", + "scripts/worker.js": "self.postMessage('script-relative decoy');", + "scripts/sw.js": "self.postMessage('script-relative decoy');", + }); + + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + for (const [source, expression] of Object.entries(expressions)) { + const node = result.graph.nodes.find( + ({ kind, observations }) => + (kind === "worker" || kind === "service-worker") && + observations.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ), + ); + expect(node?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: null, + expression, + resolved_path: null, + }), + }), + ); + expect( + result.graph.edges.some( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === node?.node_id, + ), + ).toBe(false); + } + }, +); + cliTest( "bounds repeated ambient alias branches through the compiled CLI", async ({ cli }) => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 8387acdaae..a575e41a8a 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts @@ -148,7 +148,9 @@ it("analyzes a plain long-line loader without package metadata", async () => { for (const node of nodes) { expect(node.identity).toMatchObject({ strategy: "artifact-local-key", - key: node.kind === "endpoint" ? literalEndpointKey("") : "", + // A Worker string is relative to the unestablished document base, so + // its identity is the retained expression rather than a specifier. + key: node.kind === "endpoint" ? literalEndpointKey("") : 'new Worker("")', }); expect(node.observations.map(({ label }) => label)).toEqual([null]); } diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts index 55807d6de7..1e1f4d970b 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts @@ -55,7 +55,7 @@ const graphIdentities = async (source: string) => { it.each([ "fetch('https://api.example.com/v1/users');", "localStorage.setItem('token', 1);", - "new Worker('./worker.js');", + "new Worker(new URL('./worker.js', import.meta.url));", "const dep = require('./dep.js'); dep();", "import('./dep.js');", ])( diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index d9d4c8ffb5..21d5719e06 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -168,13 +168,13 @@ it("associates each source-map URL with its edge when aliases share one physical }); }); -it("resolves a bare source-map file name and worker URL next to the script", async () => { +it("resolves a bare source-map file name and module worker URL next to the script", async () => { const root = await createTestTempDirectory("rea-source-map-relative-"); await mkdir(join(root, "dist")); await writeFile( join(root, "dist", "app.js"), [ - 'new Worker("worker.js");', + 'new Worker(new URL("worker.js", import.meta.url));', 'navigator.serviceWorker.register("sw.js");', "//# sourceMappingURL=app.js.map", ].join("\n"), @@ -228,12 +228,12 @@ it("resolves a bare source-map file name and worker URL next to the script", asy expect(workerEdge?.properties).toMatchObject({ resolved_path: "dist/worker.js", }); - const serviceWorkerEdge = result.graph.edges.find( - ({ relation, source_node_id }) => - relation === "maps_to" && - nodes.get(source_node_id)?.kind === "service-worker", - ); - expect(serviceWorkerEdge?.properties).toMatchObject({ - resolved_path: "dist/sw.js", - }); + // A service worker URL is relative to the unestablished document base. + expect( + result.graph.edges.some( + ({ relation, source_node_id }) => + relation === "maps_to" && + nodes.get(source_node_id)?.kind === "service-worker", + ), + ).toBe(false); }); diff --git a/tests/fixtures/javascriptRuntimeReconciliation.ts b/tests/fixtures/javascriptRuntimeReconciliation.ts index baca10aeb6..2f0310ce46 100644 --- a/tests/fixtures/javascriptRuntimeReconciliation.ts +++ b/tests/fixtures/javascriptRuntimeReconciliation.ts @@ -7,7 +7,7 @@ import { createWebTextArtifact } from "../../src/domain/webContentArtifact.js"; import { analyzeJavaScriptApplication } from "../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "./temporaryDirectory.js"; -export const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; +export const SOURCE = `const worker = new Worker(new URL("./worker.js", import.meta.url));\nexport const observed = worker;\n`; export const applicationFixture = async (): Promise => { const root = await createTestTempDirectory("rea-runtime-reconciliation-"); From 4692bb33300c7cb77b095b6cc0c33da861c1b860 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:23:06 -0700 Subject: [PATCH 05/21] fix(reference): stop attributing overwritten require members to Node (#3801) * fix(reference): stop attributing overwritten require members to Node import-reference-source treated require.resolve(...) and require.main(...) as Node's resolver even after the file replaced that member, so a literal argument became a parsed internal dependency that the source never loads. Attribute member calls only while the loader's resolve and main members keep Node's identity: no write to either member or a computed member (assignment, update, delete, destructuring) and no use of require as a value where a mutator or alias could replace them. Bare require(...) calls are unaffected. Fixes #3393 Co-Authored-By: Claude Opus 5.5 * fix(reference): keep optional and wrapped loader uses intact An optional call require?.(...) or a TypeScript or parenthesis wrapper around require was read as require escaping as a value, which disabled every member-call link in the file. Look through inert wrappers and accept optional calls before deciding the loader escaped, and add controls for bare calls after a member overwrite, a local loader's member write, and optional calls. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/domain/referenceSourceRequireBindings.ts | 87 +++++++++++++++++++ .../referenceSourceShadowedRequire.test.ts | 64 ++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index f2f8d1ef54..234121a512 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -1,5 +1,11 @@ import * as t from "@babel/types"; +import { + semanticStaticPropertyKey, + semanticWriteSiteTarget, + semanticWriteTargets, + unwrapJavaScriptExpression, +} from "./javascript/javascriptAstValues.js"; import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js"; import { semanticBindingId, @@ -49,6 +55,22 @@ export const unshadowedReferenceSourceRequireCalls = ( (reference.resolution === "unbound" || reference.bindingId === programRequireBinding), ); + const isLoaderReference = (identifier: t.Identifier): boolean => { + const start = identifier.loc?.start; + const reference = + start === undefined + ? undefined + : references.get(`${start.line}:${start.column}`); + return ( + (allowsUnboundRequire && reference?.resolution === "unbound") || + (reference?.resolution === "resolved" && + reference.bindingId === programRequireBinding) + ); + }; + const loaderMembersIntact = requireLoaderMembersIntact( + file.program, + isLoaderReference, + ); const calls = new Set(); traverseJavaScriptAst(file.program, { @@ -64,6 +86,9 @@ export const unshadowedReferenceSourceRequireCalls = ( ) ) return; + // `require.resolve` and `require.main` denote Node's members only + // while no visible write or escape can have replaced them. + if (!t.isIdentifier(node.callee) && !loaderMembersIntact) return; const reference = references.get(`${start.line}:${start.column}`); if ( (allowsUnboundRequire && @@ -80,6 +105,68 @@ export const unshadowedReferenceSourceRequireCalls = ( return calls; }; +/** + * Whether the loader's `resolve` and `main` members keep Node's identity. A + * write to either member or to a computed member, or `require` used as a + * value other than a call or member read (where a mutator or alias can + * replace members), leaves that identity unknown. + */ +const requireLoaderMembersIntact = ( + program: t.Program, + isLoaderReference: (identifier: t.Identifier) => boolean, +): boolean => { + let intact = true; + const isLoaderMemberTarget = (target: t.Node): boolean => { + if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target)) + return false; + const object = unwrapJavaScriptExpression(target.object).node; + if (!t.isIdentifier(object, { name: "require" })) return false; + if (!isLoaderReference(object)) return false; + const key = semanticStaticPropertyKey(target.property, target.computed); + return key === null || key === "resolve" || key === "main"; + }; + traverseJavaScriptAst(program, { + enter: (node, parent, readAncestors) => { + if (!intact) return; + const site = semanticWriteSiteTarget(node); + if (site !== null) { + for (const target of semanticWriteTargets(site)) + if (isLoaderMemberTarget(target)) intact = false; + return; + } + if ( + !t.isIdentifier(node, { name: "require" }) || + parent === null || + !isLoaderReference(node) + ) + return; + // Inert parentheses and TypeScript wrappers do not change how the + // loader value is used. + let used: t.Node = node; + let user: t.Node | undefined = parent; + const read = readAncestors(); + const ancestors = read.at(-1) === parent ? read : [...read, parent]; + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + user = ancestors[index]; + if ( + user === undefined || + unwrapJavaScriptExpression(user).node !== node + ) + break; + used = user; + } + const usedAsLoader = + ((t.isCallExpression(user) || t.isOptionalCallExpression(user)) && + user.callee === used) || + ((t.isMemberExpression(user) || t.isOptionalMemberExpression(user)) && + user.object === used) || + t.isUnaryExpression(user, { operator: "typeof" }); + if (!usedAsLoader) intact = false; + }, + }); + return intact; +}; + const contains = (container: t.Node, node: t.Node): boolean => typeof container.start === "number" && typeof container.end === "number" && diff --git a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts index 8c438e6473..0c88a38014 100644 --- a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts @@ -119,3 +119,67 @@ it("resolves require calls by lexical binding and source mode", async () => { ["top-level-var.cjs", "loaded.cjs"], ]); }); + +it("does not attribute overwritten require members to Node's resolver", async () => { + const root = await createTestTempDirectory("rea-reference-require-member-"); + const sources: Record = { + "assigned.cjs": + "require.resolve = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));", + "defined.cjs": + "Object.defineProperty(require, 'resolve', { value: () => 'custom' });\nconsole.log(require.resolve('./loaded.cjs'));", + "computed.cjs": + "const key = 'resolve';\nrequire[key] = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));", + "control.cjs": "console.log(typeof require.resolve('./loaded.cjs'));", + // A member overwrite does not change bare loader calls. + "bare-after-overwrite.cjs": + "require.resolve = () => 'custom';\nconsole.log(require('./loaded.cjs'));", + // A local loader's member write does not touch Node's loader. + "local-loader.cjs": + "function patch(require) { require.resolve = () => 'custom'; }\npatch({});\nconsole.log(typeof require.resolve('./loaded.cjs'));", + // Optional calls and inert wrappers do not let the loader escape. + "optional-call.cjs": + "require?.('./loaded.cjs');\nconsole.log(typeof (require).resolve('./loaded.cjs'));", + "loaded.cjs": "module.exports = 'real';\n", + }; + await Promise.all( + Object.entries(sources).map(([path, source]) => + writeFile(join(root, path), source), + ), + ); + const runNode = promisify(execFile); + for (const [path, stdout] of [ + ["assigned.cjs", "custom\n"], + ["defined.cjs", "custom\n"], + ["computed.cjs", "custom\n"], + ["control.cjs", "string\n"], + ["bare-after-overwrite.cjs", "real\n"], + ["local-loader.cjs", "string\n"], + ["optional-call.cjs", "string\n"], + ] as const) + expect( + ( + await runNode(process.execPath, [join(root, path)], { + cwd: root, + timeout: 5_000, + }) + ).stdout, + ).toBe(stdout); + + const result = await importReferenceSource({ + root, + caller: "reference-require-member-test", + policy: { secretPatterns: [] }, + }); + if (!result.ok) throw result.error; + expect( + result.value.relationships + .filter(({ kind }) => kind === "requires") + .map(({ from_path, to }) => [from_path, to]) + .sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")), + ).toEqual([ + ["bare-after-overwrite.cjs", "loaded.cjs"], + ["control.cjs", "loaded.cjs"], + ["local-loader.cjs", "loaded.cjs"], + ["optional-call.cjs", "loaded.cjs"], + ]); +}); From baad9eebe4c4a381be1e69ab0021acfea382f339 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 20:21:34 -0700 Subject: [PATCH 06/21] fix(javascript): reject FIFO recovery inputs without blocking (#3788) * fix(javascript): reject FIFO recovery inputs without blocking scanRecoveryFile opened the selected path with a blocking O_RDONLY open before checking that it is a regular file. A FIFO with no writer blocks that open in the libuv threadpool, so cancellation and the recovery deadline could not settle the read, and the process could not exit. Admit the input through the shared openRegularFile owner, which opens with O_NONBLOCK and verifies a regular file before any read. A FIFO, directory or socket now fails admission immediately with the existing regular-file error; symlink rejection and cancellation are unchanged. Fixes #3332 Co-Authored-By: Claude Opus 5.5 * fix(javascript): keep a failed admission close owned on cancellation Rethrow RegularFileAdmissionFailure before mapping cancellation, so a descriptor whose close failed stays owned by its error. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/javascript/recovery/RecoveryFiles.test.ts | 55 +++++++++++++++++++ src/javascript/recovery/RecoveryFiles.ts | 27 +++++++-- .../wakaruIntegration.test.ts | 32 ++++++++++- 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 src/javascript/recovery/RecoveryFiles.test.ts diff --git a/src/javascript/recovery/RecoveryFiles.test.ts b/src/javascript/recovery/RecoveryFiles.test.ts new file mode 100644 index 0000000000..574699abd5 --- /dev/null +++ b/src/javascript/recovery/RecoveryFiles.test.ts @@ -0,0 +1,55 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it, vi } from "vitest"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; +import { + openRegularFile, + RegularFileAdmissionFailure, +} from "../../filesystem/RegularFile.js"; +import { readWithoutFifoWriter } from "../../../tests/fixtures/fifoInput.js"; +import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; +import { readRecoveryFile } from "./RecoveryFiles.js"; + +vi.mock("../../filesystem/RegularFile.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, openRegularFile: vi.fn(actual.openRegularFile) }; +}); + +it.skipIf(process.platform === "win32")( + "rejects a named pipe without waiting for a writer", + async () => { + const root = await createTestTempDirectory("rea-recovery-fifo-"); + const regular = join(root, "regular.js"); + await writeFile(regular, "module.exports = 1;\n"); + await expect(readRecoveryFile(regular, 1024)).resolves.toHaveLength(20); + + const fifoPath = join(root, "sample.js"); + await promisify(execFile)("mkfifo", [fifoPath]); + const outcome = await readWithoutFifoWriter(fifoPath, () => + readRecoveryFile(fifoPath, 1024).catch((cause: unknown) => cause), + ); + if (outcome.state !== "completed") + throw new Error("Recovery input read waited for a FIFO writer"); + expect(outcome.result).toBeInstanceOf(AnalysisOutputError); + }, +); + +it("keeps a failed admission close owned when cancellation is also requested", async () => { + const owner = new OwnedFileHandle({ fd: -1 } as never); + const failure = new RegularFileAdmissionFailure( + "/selected.js", + new Error("cancelled during admission"), + new Error("close failed"), + owner, + ); + vi.mocked(openRegularFile).mockRejectedValueOnce(failure); + const controller = new AbortController(); + controller.abort(); + await expect( + readRecoveryFile("/selected.js", 1024, controller.signal), + ).rejects.toBe(failure); +}); diff --git a/src/javascript/recovery/RecoveryFiles.ts b/src/javascript/recovery/RecoveryFiles.ts index cd1dcb6669..6cea1b81b6 100644 --- a/src/javascript/recovery/RecoveryFiles.ts +++ b/src/javascript/recovery/RecoveryFiles.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; -import { constants } from "node:fs"; -import { lstat, mkdir, open, opendir, writeFile } from "node:fs/promises"; +import { lstat, mkdir, opendir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { TextDecoder } from "node:util"; import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js"; @@ -10,6 +9,11 @@ import { AnalysisOutputError, AnalysisTimeoutError, } from "../../domain/analysisErrorCore.js"; +import { + NonRegularFileReadError, + openRegularFile, + RegularFileAdmissionFailure, +} from "../../filesystem/RegularFile.js"; import { RECOVERY_LIMITS } from "./WakaruRelease.js"; const OPERATION = "recover_javascript_sources"; @@ -25,11 +29,26 @@ const scanRecoveryFile = async ( consume: (chunk: Buffer) => void, signal?: AbortSignal, ) => { - const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + // A FIFO without a writer must be rejected before it can block the open. + const handle = await openRegularFile(path, { + symlinks: "reject", + signal, + }).catch((cause: unknown) => { + // A descriptor whose close failed stays owned by its error, even when + // cancellation was also requested. + if (cause instanceof RegularFileAdmissionFailure) throw cause; + checkRecoveryCancellation(signal); + if (cause instanceof NonRegularFileReadError) + throw new AnalysisOutputError( + OPERATION, + `Expected a regular file no larger than ${String(maximum)} bytes: ${path}`, + ); + throw cause; + }); const hash = createHash("sha256"); try { const before = await handle.stat(); - if (!before.isFile() || before.size > maximum) + if (before.size > maximum) throw new AnalysisOutputError( OPERATION, `Expected a regular file no larger than ${String(maximum)} bytes: ${path}`, diff --git a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts index 9cb27999eb..8e34ba1973 100644 --- a/tests/boundary/javascript-recovery/wakaruIntegration.test.ts +++ b/tests/boundary/javascript-recovery/wakaruIntegration.test.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import { access, mkdir, open, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; import { describe, expect, it, vi } from "vitest"; import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js"; import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js"; @@ -11,6 +13,7 @@ import { assertRecoveryCleanup, recoveryFixture, } from "../../fixtures/javascript-recovery/provider.js"; +import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js"; describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( "Wakaru recovery boundary", @@ -548,3 +551,30 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( }); }, ); + +describe.skipIf(process.platform !== "linux" || process.arch !== "x64")( + "Wakaru input admission", + () => { + it("rejects a named pipe without waiting for a writer or launching recovery", async () => { + const fixture = await recoveryFixture(); + const fifoPath = join(dirname(fixture.path), "pipe.js"); + await promisify(execFile)("mkfifo", [fifoPath]); + const outcome = await readWithoutFifoWriter(fifoPath, () => + fixture.service.recover({ + path: fifoPath, + output_directory: fixture.output, + }), + ); + if (outcome.state !== "completed") + throw new Error("Recovery input read waited for a FIFO writer"); + if (outcome.result.ok) throw new Error("expected non-regular input"); + expect(projectAnalysisError(outcome.result.error).code).toBe( + "invalid_request", + ); + expect(fixture.launches).toHaveLength(0); + await expect(access(fixture.output)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); From 847b7328cf73907511acc71c554613af31a87b5b Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 20:21:49 -0700 Subject: [PATCH 07/21] fix(comparison): keep unnamed reference endpoints distinct or unknown (#3809) * fix(comparison): keep unnamed reference endpoints distinct or unknown compare_functions projected reference endpoints to procedure names only, so two edges whose targets had no named procedure both became null and compared equal, even when their recorded addresses differed. Identify an unnamed endpoint by its address when both dossiers describe the same artifact, where that address names one location. Across artifacts the endpoint has no stable identity, so equal-looking projections that contain one leave the references dimension unknown with an explicit limitation. Named endpoints still survive relocation. Fixes #3388 Co-Authored-By: Claude Opus 5.5 * fix(comparison): report known reference-kind changes before unnamed-endpoint uncertainty Compare known reference kinds first and keep a differing kind as a change; the unnamed-endpoint limitation now applies only when equality remains unproven. The kind projection uses the same endpoint identity as the endpoint projection. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/domain/functionComparison.test.ts | 58 +++++++++++++++- src/domain/functionComparisonDimensions.ts | 39 +++++++++-- src/domain/functionComparisonNormalization.ts | 66 +++++++++++++++---- 3 files changed, 142 insertions(+), 21 deletions(-) diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index a8f6458383..adcceaa524 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -599,11 +599,20 @@ describe("function comparison ordering controls", () => { makeEvidence("e", rightWithoutNativeApi), ); - expect(comparison.status).toBe("unchanged"); - for (const dimension of ["calls", "references", "strings_names"] as const) + for (const dimension of ["calls", "strings_names"] as const) expect( comparison.dimensions.find((item) => item.dimension === dimension), ).toMatchObject({ status: "unchanged" }); + // Unnamed endpoints in different artifacts have no stable identity. + expect( + comparison.dimensions.find((item) => item.dimension === "references"), + ).toMatchObject({ + status: "unknown", + limitations: expect.arrayContaining([ + expect.stringContaining("Unnamed reference endpoints"), + ]), + }); + expect(comparison.status).toBe("unknown"); }); }); @@ -1274,3 +1283,48 @@ it.each([ } }, ); + +describe("function comparison unnamed reference endpoints", () => { + it("distinguishes unnamed reference targets by address within one artifact", () => { + const unnamedTarget = (target: string) => + functionDossierSchema.parse({ + ...dossierWithReference("0x1000", "call"), + outgoing_references: [ + { + ...dossierWithReference("0x1000", "call").outgoing_references[0], + target_address: target, + target_procedure: null, + }, + ], + }); + const reference = (left: string, right: string, digits = ["a", "a"]) => + compareFunctions( + observe(digits[0] ?? "a", unnamedTarget(left)), + observe(digits[1] ?? "a", unnamedTarget(right)), + ).dimensions.find(({ dimension }) => dimension === "references"); + expect(reference("0x1010", "0x1020")).toMatchObject({ status: "changed" }); + expect(reference("0x1010", "0x1010")).toMatchObject({ + status: "unchanged", + }); + // Across artifacts the same address does not identify the same target. + expect(reference("0x1010", "0x1010", ["a", "b"])).toMatchObject({ + status: "unknown", + }); + }); + + it("keeps known reference-kind changes beside unnamed endpoints", () => { + const unnamed = (kind: "call" | "data") => { + const edge = dossierWithReference("0x1000", kind).outgoing_references[0]; + return functionDossierSchema.parse({ + ...dossierWithReference("0x1000", kind), + outgoing_references: [{ ...edge, target_procedure: null }], + }); + }; + expect( + compareFunctions( + observe("a", unnamed("call")), + observe("b", unnamed("data")), + ).dimensions.find(({ dimension }) => dimension === "references"), + ).toMatchObject({ status: "changed" }); + }); +}); diff --git a/src/domain/functionComparisonDimensions.ts b/src/domain/functionComparisonDimensions.ts index f935e228bd..0f9eb91bd1 100644 --- a/src/domain/functionComparisonDimensions.ts +++ b/src/domain/functionComparisonDimensions.ts @@ -253,13 +253,23 @@ const swiftLiteralCoverage = ( ? "absent" : "complete"; +const hasUnnamedEndpoint = (snapshot: FunctionSnapshot): boolean => + [ + ...snapshot.dossier.incoming_references, + ...snapshot.dossier.outgoing_references, + ].some( + (edge) => edge.source_procedure === null || edge.target_procedure === null, + ); + const compareReferences = ( left: FunctionSnapshot, right: FunctionSnapshot, context: ComparisonContext, ): FunctionDimension => { - const leftProjection = referenceProjection(left); - const rightProjection = referenceProjection(right); + const sameArtifact = + left.subject.digest.sha256 === right.subject.digest.sha256; + const leftProjection = referenceProjection(left, sameArtifact); + const rightProjection = referenceProjection(right, sameArtifact); if (leftProjection.length === 0 && rightProjection.length === 0) return compareValues( "references", @@ -284,9 +294,28 @@ const compareReferences = ( "Reference endpoints differ independently of provider reference-kind metadata.", ], }); - const leftKinds = referenceKindProjection(left); - const rightKinds = referenceKindProjection(right); - if (leftKinds !== null && rightKinds !== null) + const leftKinds = referenceKindProjection(left, sameArtifact); + const rightKinds = referenceKindProjection(right, sameArtifact); + const kindsKnown = leftKinds !== null && rightKinds !== null; + // Differing known edge kinds are a change whatever the endpoint identity. + if ( + kindsKnown && + canonicalJson(leftKinds, "Function comparison") !== + canonicalJson(rightKinds, "Function comparison") + ) + return compareValues("references", leftKinds, rightKinds, context); + // Equal projections with unknown endpoints do not establish equal targets. + if (!sameArtifact && (hasUnnamedEndpoint(left) || hasUnnamedEndpoint(right))) + return unresolvedDimension({ + dimension: "references", + links: context.links, + leftCount: leftProjection.length, + rightCount: rightProjection.length, + limitations: [ + "Unnamed reference endpoints have no stable identity across different artifacts, so equal named endpoints do not establish equal references.", + ], + }); + if (kindsKnown) return compareValues("references", leftKinds, rightKinds, context); return unresolvedDimension({ dimension: "references", diff --git a/src/domain/functionComparisonNormalization.ts b/src/domain/functionComparisonNormalization.ts index f32220b0b0..85b7d0e449 100644 --- a/src/domain/functionComparisonNormalization.ts +++ b/src/domain/functionComparisonNormalization.ts @@ -72,22 +72,52 @@ export const normalizeCfg = ( return graph; }; -export const referenceProjection = (snapshot: FunctionSnapshot) => - sorted([ - ...snapshot.dossier.incoming_references.map((item) => ({ - direction: "in", - source: item.source_procedure?.name ?? null, - target: item.target_procedure?.name ?? null, +type ReferenceEdge = FunctionSnapshot["dossier"]["outgoing_references"][number]; + +/** + * A named endpoint survives relocation. An unnamed endpoint is identified + * only by its address, which names one location only within one artifact; + * across artifacts it stays unknown (null). + */ +const referenceEndpoint = ( + procedure: ReferenceEdge["source_procedure"], + address: string, + sameArtifact: boolean, +): string | { readonly address: string } | null => + procedure !== null ? procedure.name : sameArtifact ? { address } : null; + +export const referenceProjection = ( + snapshot: FunctionSnapshot, + sameArtifact: boolean, +) => + sorted( + [ + ...snapshot.dossier.incoming_references.map((item) => ({ + direction: "in", + item, + })), + ...snapshot.dossier.outgoing_references.map((item) => ({ + direction: "out", + item, + })), + ].map(({ direction, item }) => ({ + direction, + source: referenceEndpoint( + item.source_procedure, + item.source_address, + sameArtifact, + ), + target: referenceEndpoint( + item.target_procedure, + item.target_address, + sameArtifact, + ), })), - ...snapshot.dossier.outgoing_references.map((item) => ({ - direction: "out", - source: item.source_procedure?.name ?? null, - target: item.target_procedure?.name ?? null, - })), - ]); + ); export const referenceKindProjection = ( snapshot: FunctionSnapshot, + sameArtifact: boolean, ): readonly unknown[] | null => { const edges = [ ...snapshot.dossier.incoming_references.map((edge) => ({ @@ -108,8 +138,16 @@ export const referenceKindProjection = ( ); return { direction, - source: edge.source_procedure?.name ?? null, - target: edge.target_procedure?.name ?? null, + source: referenceEndpoint( + edge.source_procedure, + edge.source_address, + sameArtifact, + ), + target: referenceEndpoint( + edge.target_procedure, + edge.target_address, + sameArtifact, + ), type: edge.kind.type, flow: edge.kind.flow, call: edge.kind.call, From 0781716788f553aba739b339560402b3fe8eb0ec Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 20:22:02 -0700 Subject: [PATCH 08/21] fix(cli): accept --flag=value for global output flags (#3811) * fix(cli): accept --flag=value for global output flags `rea --format=json` failed with "Unknown flag: --format". `--filter-output=...`, `--token-limit=...` and `--token-offset=...` failed the same way. Incur reads these global flags only in spaced form, so the equals form fell through to the command parser. Command options already accept both forms, and `rea mcp doctor` parses `--format=` itself. A valued output flag with no value (`--format` last, or `--format=`) also reported the flag as unknown with code UNKNOWN. normalizeCliOutputArguments, in the module that owns output-argument interpretation, now splits the equals forms of the four valued output flags. The entry point passes the normalized argv to rea's output validation and to Incur, so both read the same effective values. A missing value now fails with MISSING_OUTPUT_VALUE and names the flag. Co-Authored-By: Claude Opus 5.5 * fix(cli): detect missing output values while parsing output flags The missing-value check scanned argv again after parsing, so it also checked tokens the parser had already consumed as values. For example, `--filter-output --format` selects the field `--format`, yet the second scan reported `--format` as missing a value. parseCliOutputArguments now records the first valued flag it skips for lack of a value, in the same pass that consumes values. Cover normalizeCliOutputArguments directly: values containing `=`, empty values, and command options that only share a prefix. Export it in the dispatcher test's runtime stub as well. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/cli.md | 4 +- scripts/rea.mjs | 10 +-- src/cliOutput.ts | 54 ++++++++++++++-- tests/boundary/cli/cliOutput.test.ts | 92 +++++++++++++++++++++++---- tests/boundary/cli/dispatcher.test.ts | 2 +- 5 files changed, 137 insertions(+), 25 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index e475964fd2..0922f584a7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -314,7 +314,9 @@ diagnostics appear inside the report when `--full-output` is selected. Log truncation alone does not change the diagnostic health or exit status. The default terminal format is TOON. Use `--json` when saving results for a -JSON consumer. `--json` is indented on a terminal and compact when piped or +JSON consumer. Valued output flags (`--format`, `--filter-output`, +`--token-limit`, `--token-offset`) accept `--flag value` or `--flag=value`; a +missing value fails with `MISSING_OUTPUT_VALUE` before the command runs. `--json` is indented on a terminal and compact when piped or redirected, which keeps large results smaller for agents and scripts; pipe through `jq .` for indented files. Output selection and formatting do not change operation status. diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 3db1cbf552..da48782b8f 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -74,12 +74,14 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { const { createCliResultOutput } = await import("../dist/cli-runtime/cliResultOutput.js"); const { + normalizeCliOutputArguments, renderCliOutputArgumentError, renderEmptyFilteredCliOutput, validateCliOutputArguments, } = await import("../dist/cli-runtime/cliOutput.js"); guardClosedStdout(); - const outputArguments = validateCliOutputArguments(args); + const cliArgs = normalizeCliOutputArguments(args); + const outputArguments = validateCliOutputArguments(cliArgs); const configurationError = cliConfigurationError(process.env); if (configurationError !== undefined) { process.stderr.write(`${configurationError}\n`); @@ -89,8 +91,8 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.exitCode = 1; } else { let wroteOutput = false; - const resultOutput = createCliResultOutput(args, process.stdout); - await createCli(process.env, resultOutput).serve(args, { + const resultOutput = createCliResultOutput(cliArgs, process.stdout); + await createCli(process.env, resultOutput).serve(cliArgs, { stdout: (output) => { if (resultOutput?.handled) { if (resultOutput.failed && !stdoutClosed) @@ -104,7 +106,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { exit: (code) => process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : code), }); if (!wroteOutput && !resultOutput?.handled) - writeStdout(renderEmptyFilteredCliOutput(args) ?? ""); + writeStdout(renderEmptyFilteredCliOutput(cliArgs) ?? ""); } } diff --git a/src/cliOutput.ts b/src/cliOutput.ts index ccfaf6b62c..e175983e4c 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -3,13 +3,40 @@ const UNSUPPORTED_OUTPUT_COMBINATION_MESSAGE = type StructuredOutputFormat = "json" | "jsonl" | "yaml"; const INCUR_OUTPUT_FORMATS = new Set(["toon", "json", "yaml", "md", "jsonl"]); +/** Global output flags that take a value. */ +const VALUED_OUTPUT_FLAGS = new Set([ + "--format", + "--filter-output", + "--token-limit", + "--token-offset", +]); + +/** + * Split `--flag=value` forms of the valued output flags. Incur reads these + * flags only in spaced form, so `--format=json` otherwise reaches the command + * parser and fails as an unknown flag. Command options accept both forms. + */ +export const normalizeCliOutputArguments = ( + arguments_: readonly string[], +): string[] => + arguments_.flatMap((argument) => { + const separator = argument.indexOf("="); + if (separator < 0) return [argument]; + const flag = argument.slice(0, separator); + return VALUED_OUTPUT_FLAGS.has(flag) + ? [flag, argument.slice(separator + 1)] + : [argument]; + }); export type CliOutputArgumentValidation = | { readonly ok: true } | { readonly ok: false; readonly format: StructuredOutputFormat; - readonly code: "UNSUPPORTED_OUTPUT_COMBINATION" | "INVALID_OUTPUT_FILTER"; + readonly code: + | "UNSUPPORTED_OUTPUT_COMBINATION" + | "INVALID_OUTPUT_FILTER" + | "MISSING_OUTPUT_VALUE"; readonly message: string; }; @@ -17,6 +44,8 @@ interface ParsedCliOutputArguments { readonly filterOutput: string | undefined; readonly format: string; readonly fullOutput: boolean; + /** First valued output flag that Incur skips because it has no value. */ + readonly missingValue: string | undefined; readonly parseError: boolean; readonly tokenCount: boolean; readonly tokenWindow: boolean; @@ -31,6 +60,7 @@ export const parseCliOutputArguments = ( let format: string = "toon"; let filterOutput: string | undefined; let fullOutput = false; + let missingValue: string | undefined; let tokenCount = false; let tokenLimit: number | undefined; let tokenOffset: number | undefined; @@ -38,6 +68,7 @@ export const parseCliOutputArguments = ( filterOutput, format, fullOutput, + missingValue, parseError, tokenCount, tokenWindow: @@ -47,8 +78,8 @@ export const parseCliOutputArguments = ( }); for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; - // Incur scans builtins across `--` and accepts only spaced value forms. - // Mirror those effective values at the executable output boundary. + // Incur scans builtins across `--` and reads spaced value forms, which + // normalizeCliOutputArguments has produced. Mirror those effective values. if (argument === "--json") { format = "json"; continue; @@ -61,14 +92,14 @@ export const parseCliOutputArguments = ( if (!INCUR_OUTPUT_FORMATS.has(value)) return parsed(true); format = value; index += 1; - } + } else missingValue ??= argument; continue; } if (argument === "--filter-output") { if (arguments_[index + 1]) { filterOutput = arguments_[index + 1]; index += 1; - } + } else missingValue ??= argument; continue; } if (argument === "--token-limit" || argument === "--token-offset") { @@ -80,7 +111,7 @@ export const parseCliOutputArguments = ( if (argument === "--token-limit") tokenLimit = numericValue; else tokenOffset = numericValue; index += 1; - } + } else missingValue ??= argument; continue; } if (argument === "--full-output") fullOutput = true; @@ -111,7 +142,7 @@ const hasUnclosedFilterSlice = (expression: string): boolean => { export const validateCliOutputArguments = ( arguments_: readonly string[], ): CliOutputArgumentValidation => { - const { filterOutput, format, parseError, tokenWindow } = + const { filterOutput, format, missingValue, parseError, tokenWindow } = parseCliOutputArguments(arguments_); if (parseError) return { ok: true }; if (filterOutput !== undefined && hasUnclosedFilterSlice(filterOutput)) @@ -132,6 +163,15 @@ export const validateCliOutputArguments = ( code: "UNSUPPORTED_OUTPUT_COMBINATION", message: UNSUPPORTED_OUTPUT_COMBINATION_MESSAGE, }; + // Incur skips a valued flag without a value and then reports the flag + // itself as unknown. Name the missing value instead. + if (missingValue !== undefined) + return { + ok: false, + format: format === "yaml" || format === "jsonl" ? format : "json", + code: "MISSING_OUTPUT_VALUE", + message: `${missingValue} requires a value, as in ${missingValue} or ${missingValue}=.`, + }; return { ok: true }; }; diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 44b3ac2346..351a49892c 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; import { + normalizeCliOutputArguments, renderCliOutputArgumentError, renderEmptyFilteredCliOutput, validateCliOutputArguments, @@ -58,6 +59,50 @@ describe("CLI output argument boundary", () => { }); }); +describe("CLI output flag value forms", () => { + it("splits only the equals forms of valued output flags", () => { + expect( + normalizeCliOutputArguments([ + "inspect-sqlite-database", + "--table=a=b", + "--format=json", + "--filter-output=a.b,c[0,1]", + "--token-limit=5", + "--token-offset=", + "--json", + "--format-hint=x", + ]), + ).toEqual([ + "inspect-sqlite-database", + "--table=a=b", + "--format", + "json", + "--filter-output", + "a.b,c[0,1]", + "--token-limit", + "5", + "--token-offset", + "", + "--json", + "--format-hint=x", + ]); + }); + + it("reports a missing value only for a flag the parser did not consume", () => { + expect( + validateCliOutputArguments([ + "capabilities", + "--json", + "--filter-output", + "--format", + ]), + ).toEqual({ ok: true }); + expect( + validateCliOutputArguments(["capabilities", "--json", "--format"]), + ).toMatchObject({ ok: false, code: "MISSING_OUTPUT_VALUE" }); + }); +}); + describe("unclosed CLI output filter slices", () => { it.each([ "providers[", @@ -153,7 +198,7 @@ describe("CLI output valued-flag parsing", () => { async ({ cli }) => { for (const arguments_ of [ ["providers", "--", "--json", "--token-limit", "5"], - ["providers", "--json", "--format=toon", "--token-limit", "5"], + ["providers", "--format=json", "--token-limit=5"], ]) { const result = await cli.run({ arguments: arguments_ }); expect(result.exitCode).toBe(1); @@ -183,22 +228,45 @@ describe("CLI output valued-flag parsing", () => { ); workspaceCliTest( - "does not treat empty or equals-style output values as active builtins", + "names missing output values and honors equals-style output values", async ({ cli }) => { - for (const arguments_ of [ - ["capabilities", "--json", "--format", ""], - ["capabilities", "--json", "--filter-output", ""], - ["capabilities", "--json", "--token-limit", ""], - ["capabilities", "--json", "--token-limit=5"], - ]) { + for (const [arguments_, flag] of [ + [["capabilities", "--json", "--format", ""], "--format"], + [["capabilities", "--json", "--filter-output", ""], "--filter-output"], + [["capabilities", "--json", "--token-limit", ""], "--token-limit"], + [["capabilities", "--json", "--token-offset="], "--token-offset"], + [["capabilities", "--format"], "--format"], + ] as const) { const result = await cli.run({ arguments: arguments_ }); expect(result.exitCode).toBe(1); - expect(result.json).toBeDefined(); expect(result.stdout).not.toContain("[truncated:"); - expect(JSON.stringify(result.json)).not.toContain( - "UNSUPPORTED_OUTPUT_COMBINATION", - ); + expect(result.json).toMatchObject({ + ok: false, + error: { + code: "MISSING_OUTPUT_VALUE", + message: expect.stringContaining(`${flag} requires a value`), + }, + }); } + const equalsForms = await cli.run({ + arguments: [ + "capabilities", + "--format=json", + "--filter-output=capabilities[0,1]", + ], + }); + expect(equalsForms.exitCode).toBe(0); + expect(equalsForms.json).toMatchObject({ + capabilities: [expect.any(Object)], + }); + const equalsTokenWindow = await cli.run({ + arguments: ["capabilities", "--json", "--token-limit=5"], + }); + expect(equalsTokenWindow.exitCode).toBe(1); + expect(equalsTokenWindow.json).toMatchObject({ + ok: false, + error: { code: "UNSUPPORTED_OUTPUT_COMBINATION" }, + }); const validTokenWindow = await cli.run({ arguments: ["capabilities", "--json", "--token-limit", "5"], }); diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index e1cd84a0e7..68d8fb39de 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -29,7 +29,7 @@ describe("executable dispatcher", () => { ), workspace.write( "dist/cli-runtime/cliOutput.js", - "export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", + "export const normalizeCliOutputArguments = (args) => args; export const validateCliOutputArguments = () => ({ ok: true }); export const renderCliOutputArgumentError = () => ''; export const renderEmptyFilteredCliOutput = () => undefined;\n", ), workspace.write( "dist/cli-runtime/cliResultOutput.js", From 4135c911d3a3414b17fcd8954a15f6c7ec6ae8f4 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 20:22:16 -0700 Subject: [PATCH 09/21] fix(javascript): keep recovered invalid templates dynamic in semantic effects (#3815) dataEffectLiteralString asked the literal owner for an exact value, and when a template recovered with an invalid escape had no cooked value it fell back to the raw spelling. Child-process commands, request endpoints and event names then became resolved values that are not JavaScript strings. Use only the owner's cooked value; a template without one stays dynamic. Fixes #3361 Co-authored-by: Claude Opus 5.5 --- ...SemanticAnalysis.invalid_templates.test.ts | 38 +++++++++++++++++++ .../javascriptSemanticDataEffectHelpers.ts | 11 ++++-- 2 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.invalid_templates.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.invalid_templates.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.invalid_templates.test.ts new file mode 100644 index 0000000000..ea50159a9a --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.invalid_templates.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const effects = (call: (value: string) => string, value: string) => { + const ir = analyzeJavaScriptSemantics(` + const { spawn } = require("child_process"); + const { EventEmitter } = require("events"); + ${call(value)} + `); + return { + commands: ir.childProcessSpawns.map(({ command }) => command), + endpoints: ir.requestOperations.map(({ endpoint }) => endpoint), + events: ir.eventOperations.map(({ eventName }) => eventName), + }; +}; + +const calls = (value: string) => + [ + `spawn(${value});`, + `fetch(${value});`, + `new EventEmitter().on(${value}, () => 1);`, + ].join("\n"); + +it("keeps recovered templates without a cooked value dynamic", () => { + // `\\unicode` is an invalid escape: Babel recovers it with `cooked: null`. + expect(effects(calls, "`\\unicode`")).toEqual({ + commands: [null], + endpoints: [null], + events: [null], + }); + // Valid templates keep their cooked value, including escapes. + expect(effects(calls, "`ready\\n`")).toEqual({ + commands: ["ready\n"], + endpoints: ["ready\n"], + events: ["ready\n"], + }); +}); diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 9112eacd16..5c3ed9ddd4 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -924,7 +924,11 @@ export const dataEffectPrimitive = ( return null; }; -/** Read one static string or expression-free template. */ +/** + * Read one static string or expression-free template. A template recovered + * with an invalid escape has no cooked value; its raw spelling is not a + * JavaScript string, so it stays dynamic. + */ export const dataEffectLiteralString = ( node: | t.Expression @@ -935,9 +939,8 @@ export const dataEffectLiteralString = ( ): string | null => { if (node === undefined) return null; const literal = readExactJavaScriptLiteral(node); - if (literal.found && typeof literal.value === "string") return literal.value; - return t.isTemplateLiteral(node) && node.expressions.length === 0 - ? (node.quasis[0]?.value.raw ?? null) + return literal.found && typeof literal.value === "string" + ? literal.value : null; }; From b7dcf782aac160a05363dc4ddba23040972f1c9e Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 20:42:44 -0700 Subject: [PATCH 10/21] fix(native): keep property flags unknown for unfinished quoted types (#3825) An Objective-C property attribute string whose quoted class name never closes, such as T@"NSString, was treated as complete. The missing N and R were then read as the Objective-C defaults, so the property was reported atomic and read-write, and objc_properties_categories coverage stayed complete. The documented semantics require null flags for incomplete attribute metadata. The attribute splitter now reports whether every quote closed. An incomplete string leaves atomicity and any unestablished read-only value null, while explicit N or R tokens remain established and the raw type is preserved. Each affected property adds a property_attributes_incomplete issue, which marks the facet partial. Fixes #3345 Co-authored-by: Claude Opus 5.5 --- docs/native-investigation.md | 4 +- src/native/AppleMachoFixups.test.ts | 13 +++ src/native/AppleObjcProperties.ts | 84 +++++++++---- .../nativeDispatchPropertyAttributes.test.ts | 110 ++++++++++++++++++ 4 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ecda41a465..3df66a93ff 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -378,7 +378,9 @@ complete observation. property: `N` means `nonatomic`; a complete typed attribute string without `N` means the Objective-C default `atomic`; missing or incomplete attribute metadata leaves `atomicity` and any - unestablished read-only value null. `is_meta_class`, `is_root_class`, and + unestablished read-only value null. A type attribute whose quoted class name + never closes is incomplete and marks `objc_properties_categories` partial + with `property_attributes_incomplete`. `is_meta_class`, `is_root_class`, and `ivar_count` are also nullable when the selected provider exposes only symbol names. Class implementation methods have null `is_required` and `is_optional` fields because those facts apply to protocol declarations; decoded protocol diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 318fdbf7c1..f3f84a76c1 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -983,4 +983,17 @@ describe("Objective-C property attributes", () => { expect(parsePropertyAttributes("count", "").atomicity).toBeNull(); expect(parsePropertyAttributes("count", "R").atomicity).toBeNull(); }); + + it("establishes no defaults from an unfinished quoted class name", () => { + // `,N,R` here lie inside the unterminated quote, so they are not flags. + expect(parsePropertyAttributes("title", 'T@"NSString,N,R')).toMatchObject({ + type_encoding: '@"NSString,N,R', + atomicity: null, + is_readonly: null, + }); + expect(parsePropertyAttributes("title", 'N,R,T@"NSString')).toMatchObject({ + atomicity: "nonatomic", + is_readonly: true, + }); + }); }); diff --git a/src/native/AppleObjcProperties.ts b/src/native/AppleObjcProperties.ts index cd9c0774d8..5b78b5ab9f 100644 --- a/src/native/AppleObjcProperties.ts +++ b/src/native/AppleObjcProperties.ts @@ -19,8 +19,21 @@ export interface ObjcPropertyReaders { export const parsePropertyAttributes = ( name: string, attributes: string, -): ObjcProperty => { - const tokens = attributes.length === 0 ? [] : splitAttributes(attributes); +): ObjcProperty => decodePropertyAttributes(name, attributes).property; + +/** + * Decode one attribute string. A quoted class name that never closes leaves + * the string incomplete: later attributes may be inside the unfinished type, + * so a missing `N` or `R` does not establish the Objective-C defaults. + */ +const decodePropertyAttributes = ( + name: string, + attributes: string, +): { readonly property: ObjcProperty; readonly complete: boolean } => { + const { tokens, complete } = + attributes.length === 0 + ? { tokens: [], complete: true } + : splitAttributes(attributes); const typeAttributes = tokens.filter((token) => token[0] === "T"); const typeAttribute = typeAttributes.length === 1 ? typeAttributes[0] : undefined; @@ -30,7 +43,8 @@ export const parsePropertyAttributes = ( : null; const value = (code: string): string | null => tokens.find((token) => token[0] === code)?.slice(1) ?? null; - return { + const defaultsEstablished = typeEncoding !== null && complete; + const property: ObjcProperty = { name, type_encoding: typeEncoding, attributes: tokens.map((token) => ({ @@ -39,21 +53,27 @@ export const parsePropertyAttributes = ( })), atomicity: tokens.includes("N") ? "nonatomic" - : typeEncoding === null - ? null - : "atomic", + : defaultsEstablished + ? "atomic" + : null, is_readonly: tokens.includes("R") ? true - : typeEncoding === null - ? null - : false, + : defaultsEstablished + ? false + : null, getter: value("G"), setter: value("S"), }; + return { property, complete }; }; -/** Split on commas that are outside a quoted class name in the type encoding. */ -const splitAttributes = (attributes: string): string[] => { +/** + * Split on commas that are outside a quoted class name in the type encoding, + * and report whether every quote closed. + */ +const splitAttributes = ( + attributes: string, +): { readonly tokens: string[]; readonly complete: boolean } => { const tokens: string[] = []; let current = ""; let quoted = false; @@ -65,7 +85,10 @@ const splitAttributes = (attributes: string): string[] => { } else current += character; } tokens.push(current); - return tokens.filter((token) => token.length > 0); + return { + tokens: tokens.filter((token) => token.length > 0), + complete: !quoted, + }; }; /** Decode `property_list_t { entsize_and_flags, count, property_t[] }`. */ @@ -73,8 +96,13 @@ export const readObjcPropertyList = ( read: ObjcPropertyReaders, list: bigint, admit: () => boolean, -): ObjcProperty[] => { - if (list === 0n) return []; +): { + readonly properties: ObjcProperty[]; + /** Names of properties whose attribute string is incomplete. */ + readonly incomplete: string[]; +} => { + const incomplete: string[] = []; + if (list === 0n) return { properties: [], incomplete }; const entrySize = read.u32(list) & ~3; const count = read.u32(list + 4n); if (entrySize !== 16) @@ -85,14 +113,14 @@ export const readObjcPropertyList = ( for (let index = 0; index < count; index++) { if (!admit()) break; const entry = list + 8n + BigInt(index * entrySize); - properties.push( - parsePropertyAttributes( - read.string(read.pointer(entry)), - read.string(read.pointer(entry + 8n)), - ), + const decoded = decodePropertyAttributes( + read.string(read.pointer(entry)), + read.string(read.pointer(entry + 8n)), ); + properties.push(decoded.property); + if (!decoded.complete) incomplete.push(decoded.property.name); } - return properties; + return { properties, incomplete }; }; /** Decode a property list, recording a failure for `owner` instead of throwing. */ @@ -107,7 +135,21 @@ export const readObjcPropertiesOf = ( }, ): ObjcProperty[] => { try { - return readObjcPropertyList(read, list, context.admit); + const { properties, incomplete } = readObjcPropertyList( + read, + list, + context.admit, + ); + for (const name of incomplete) + context.issues.push( + issue( + "objc_properties_categories", + "property_attributes_incomplete", + `${context.owner}.${name}: the type attribute has an unterminated quoted class name, so atomicity and unestablished read-only state are unknown`, + context.location, + ), + ); + return properties; } catch (cause: unknown) { context.issues.push( issue( diff --git a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts new file mode 100644 index 0000000000..51ab9dd4b6 --- /dev/null +++ b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts @@ -0,0 +1,110 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +/** arm64 Mach-O whose two class properties share one attribute string. */ +const machoWithPropertyAttributes = (attributes: string): Buffer => { + const bytes = Buffer.alloc(4096); + const base = 0x100000000n; + const u32 = (at: number, value: number) => bytes.writeUInt32LE(value, at); + const pointer = (at: number, offset: number) => + bytes.writeBigUInt64LE(base + BigInt(offset), at); + u32(0, 0xfeedfacf); + u32(4, 0x0100000c); + u32(12, 2); + u32(16, 1); + u32(20, 152); + u32(32, 0x19); + u32(36, 152); + bytes.write("__DATA", 40); + pointer(56, 0); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + u32(88, 5); + u32(92, 5); + u32(96, 1); + bytes.write("__objc_classlist", 104); + bytes.write("__DATA", 120); + pointer(136, 0x180); + bytes.writeBigUInt64LE(8n, 144); + u32(152, 0x180); + pointer(0x180, 0x200); + pointer(0x200 + 32, 0x300); + u32(0x300, 2); + u32(0x308, 16); + pointer(0x300 + 24, 0x400); + bytes.write("Fixture\0", 0x400); + pointer(0x340, 0x500); + u32(0x500, 16); + u32(0x504, 2); + pointer(0x508, 0x600); + pointer(0x510, 0x620); + pointer(0x518, 0x640); + pointer(0x520, 0x620); + bytes.write("first\0", 0x600); + bytes.write(`${attributes}\0`, 0x620); + bytes.write("second\0", 0x640); + return bytes; +}; + +describe.skipIf(process.platform !== "darwin")( + "native dispatch property attributes", + () => { + cliTest.for([ + ["complete nonatomic", "Tq,N", "nonatomic", false, "complete"], + ["complete default", 'T@"NSString",C', "atomic", false, "complete"], + ["unfinished quote", 'T@"NSString', null, null, "partial"], + ["unfinished quote after R", 'R,T@"NSString', null, true, "partial"], + ] as const)( + "reports established property flags only for $0 attributes", + async ([, attributes, atomicity, readonly, facet], { cli }) => { + const directory = await createTestTempDirectory("rea-objc-property-"); + const path = join(directory, "fixture.bin"); + await writeFile(path, machoWithPropertyAttributes(attributes)); + const output = await cli.run({ + arguments: ["inspect-native-dispatch-metadata", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(output.exitCode).toBe(0); + const property = { + atomicity, + is_readonly: readonly, + attributes: expect.arrayContaining([ + { name: "T", value: expect.any(String) }, + ]), + }; + expect(output.json).toMatchObject({ + normalized_result: { + result: { + objc_classes: [ + { + name: "Fixture", + properties: [ + { name: "first", ...property }, + { name: "second", ...property }, + ], + }, + ], + coverage: expect.arrayContaining([ + expect.objectContaining({ + facet: "objc_properties_categories", + status: facet, + reason: + facet === "partial" + ? expect.stringContaining( + "property_attributes_incomplete", + ) + : null, + }), + ]), + }, + }, + }); + }, + ); + }, +); From 70cd4cbfb849b61f05d97d2690372a9782831426 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:15:55 +0800 Subject: [PATCH 11/21] fix(hopper)!: verify current Linux releases with the publisher key Replace fixed Linux Hopper release pins with current vendor feed metadata, complete detached-PGP package verification under the stable publisher primary key, and authenticated package evidence for offline admission. Share parsed publisher facts across approved setup, doctor and the pre-execution guard. Preserve existing ownership, cancellation, private-display and cleanup rules. Fixes #3577 Normalize empty XDG settings, preserve backups during approved cache repair, and retain publisher cleanup through cancellation and shutdown. Exercise cold offline verification without reusing process-local proof state. Serialize approved cache publication across processes, restore old evidence on failure, and share the existing lease owner from process without changing its identities. Validation: 438 focused tests (six platform-related skips), full static/documentation checks, real Ubuntu 24.04 official Hopper 6.6.0 setup/CLI/MCP, offline ordinary-user admission, launcher mutation rejection, and cold/warm verification measurements. Official RPM metadata mismatch is tracked separately in #3578 and fails closed. BREAKING CHANGE: Older Linux Hopper builds admitted by the fixed allowlist now require retained publisher-signed package evidence or an approved update to the current public demo. Update explicit HOPPER_LAUNCHER_PATH values when migrating; the live feed exposes only its current release. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- docs/installation.md | 31 + licenses/npm-dependencies.txt | 7 +- licenses/npm-runtime-notices.txt | 176 +++- package-lock.json | 19 + package.json | 1 + scripts/hopper-demo-x11.py | 22 +- scripts/verify-linux-setup-e2e.mjs | 21 +- scripts/verify-package-environment.mjs | 9 + scripts/verify-package-platform.mjs | 25 +- scripts/verify-real-hopper-fat.mjs | 2 +- scripts/verify-real-hopper.mjs | 2 +- src/application/Doctor.fixture.ts | 12 +- src/application/Doctor.test.ts | 14 +- src/application/Doctor.ts | 11 +- src/application/DoctorDiagnostics.ts | 43 +- src/application/HopperDownload.ts | 22 +- src/application/HopperReleaseFeed.test.ts | 77 ++ src/application/HopperReleaseFeed.ts | 125 +++ src/application/LinuxHopper.test.ts | 104 ++- src/application/LinuxHopper.ts | 277 +++--- src/application/LinuxHopperPublisher.ts | 833 ++++++++++++++++++ src/application/Setup.planning.test.ts | 39 + src/application/SetupHost.ts | 43 +- src/application/SetupInstallFailure.ts | 25 +- src/application/SetupPlan.ts | 18 +- src/application/SetupTypes.ts | 2 + src/artifacts/ArtifactResourceScope.ts | 10 +- src/domain/analysisErrorProjection.ts | 3 + src/domain/hopperErrors.ts | 11 + src/ghidra/GhidraDoctor.test.ts | 1 - src/hopper/BridgeLauncher.ts | 68 +- src/hopper/HopperClient.ts | 30 +- src/hopper/HopperTargetLease.test.ts | 2 +- src/process/DetachedPgpSignature.test.ts | 128 +++ src/process/DetachedPgpSignature.ts | 101 +++ .../HopperLease.ts} | 18 + .../providers/hopperStartupRecovery.test.ts | 2 +- .../cli/doctorHopperDiscovery.test.ts | 1 + .../cli/hopperLaunchFailureCli.test.ts | 2 +- .../process/binarySessionCleanupRetry.test.ts | 2 +- .../providers/hopper/bridgeLauncher.test.ts | 193 +++- .../hopper/hopperClient.helperFailure.test.ts | 2 +- .../hopper/hopperVerificationCleanup.test.ts | 2 +- .../linuxHopperPublisherVerification.test.ts | 765 ++++++++++++++++ .../packagePlatformDiagnostics.test.ts | 45 + tests/fixtures/hopperCachePublisher.mjs | 31 + tests/fixtures/hopperOwnerLifetime.py | 3 +- tests/fixtures/hopperRejectionFeed.mjs | 36 + .../hopperLeaseDescriptorPermissions.test.ts | 2 +- 49 files changed, 3195 insertions(+), 223 deletions(-) create mode 100644 src/application/HopperReleaseFeed.test.ts create mode 100644 src/application/HopperReleaseFeed.ts create mode 100644 src/application/LinuxHopperPublisher.ts create mode 100644 src/process/DetachedPgpSignature.test.ts create mode 100644 src/process/DetachedPgpSignature.ts rename src/{hopper/HopperTargetLease.ts => process/HopperLease.ts} (94%) create mode 100644 tests/boundary/setup/linuxHopperPublisherVerification.test.ts create mode 100644 tests/fixtures/hopperCachePublisher.mjs create mode 100644 tests/fixtures/hopperRejectionFeed.mjs diff --git a/docs/installation.md b/docs/installation.md index a428fbcb16..39a53bb701 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -541,6 +541,37 @@ search permissions (`0700`) on a directory you own before retrying. On supported Linux distributions, approved setup verifies Hopper's official `.deb`, `.rpm`, or Arch package before invoking the native package manager. +The official release feed selects the current public demo without a version +pin. REA verifies the package's detached PGP signature against Hopper's stable +primary fingerprint `7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD`, then checks +that the signed package and installed launcher agree with `main_exe_hash`. +Rotating signing subkeys remain bound to that primary identity. +The feed row itself is not PGP-signed; doctor labels its display version as +`reported_release_version`, separately from the authenticated launcher identity. + +The approval plan includes the caller-selected cache location. Approved setup +retains the signed archive, public key and release metadata under +`${XDG_CACHE_HOME:-$HOME/.cache}/rea/hopper-releases/`. +An empty `XDG_CACHE_HOME` uses `$HOME/.cache`. Approved setup can repair a +damaged caller-owned entry after authenticating a fresh package; the previous +entry is preserved beside it as `.backup-`. Ordinary doctor +and launches reject damaged evidence and never repair it implicitly. +Approved publications of the same canonical cache entry share a process-backed +lease. Concurrent setup waits without moving another writer's evidence; a +failed replacement restores the previous directory when possible and reports +any retained backup path. Unavailable reads or native inspection tools do not +authorize replacing existing evidence. +Doctor and launches authenticate this evidence for offline use; an editable +digest receipt alone cannot authorize a launcher. Verification reuses at most +eight immutable package proofs per process, keyed by the complete package, +key, signature, package format and expected launcher digest. Cached file reads +still check identity and content. Without retained evidence, inspecting an +existing install requires the matching current official package and network +access. Successful live inspection can be reused within that process after +rehashing the launcher; it does not write a persistent cache or claim that a +previously observed release is still the latest. Older uncached builds require +an approved update while the feed exposes only its current release. + REA runs the supported demo build on a private Xvfb display and selects Hopper's offered demo mode for each analysis session; it does not require the user's desktop display. Unattended package-manager access requires diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt index 2f991e933b..31b00b3be0 100644 --- a/licenses/npm-dependencies.txt +++ b/licenses/npm-dependencies.txt @@ -2,7 +2,7 @@ REA npm dependency inventory Prepared from the root package-lock.json; refresh this inventory when locked dependencies change. License identifiers are upstream lockfile declarations, not a legal assessment. Runtime includes transitive and optional platform packages; development includes build, test and website tooling. -Locked package entries: 721; runtime: 177. +Locked package entries: 722; runtime: 178. Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt. @algolia/abtesting@1.25.0 | development | MIT @@ -2740,6 +2740,11 @@ oniguruma-to-es@3.1.1 | development | MIT Package: https://www.npmjs.com/package/oniguruma-to-es/v/3.1.1 Archive: https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz Integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ== +openpgp@6.3.2 | runtime | LGPL-3.0+ + Lockfile path: node_modules/openpgp + Package: https://www.npmjs.com/package/openpgp/v/6.3.2 + Archive: https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz + Integrity: sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ== oxc-parser@0.150.0 | development | MIT Lockfile path: node_modules/oxc-parser Package: https://www.npmjs.com/package/oxc-parser/v/0.150.0 diff --git a/licenses/npm-runtime-notices.txt b/licenses/npm-runtime-notices.txt index 81adcd9ab4..d9efda90fa 100644 --- a/licenses/npm-runtime-notices.txt +++ b/licenses/npm-runtime-notices.txt @@ -1,7 +1,7 @@ REA third-party npm runtime copyright, license and NOTICE texts Collected from integrity-verified locked npm archives. Release-specific supplements and upstream notice gaps are identified in their entries. External engines are separately obtained; see THIRD_PARTY_NOTICES.md. -Runtime dependency fingerprint (SHA-256): 49b181923ab240c68c64d35ecdd8f93cb5cd516c765f2282d0e0eca0bcadd13e +Runtime dependency fingerprint (SHA-256): 131100b2e1e6ad5e55b10a0baf4be2d7a0838fd724fb307b9bafd73446b0d3ec ===== @babel/helper-string-parser@8.0.6 ===== Declared license: MIT @@ -5171,6 +5171,180 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +===== openpgp@6.3.2 ===== +Declared license: LGPL-3.0+ +Archive: https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz +Integrity: sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ== +Scope: npm runtime dependency. GPLv3 terms incorporated by this LGPL text are preserved in licenses/GPL-3.0.txt. + +--- package/LICENSE --- + + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. + ===== pako@1.0.11 ===== Declared license: (MIT AND Zlib) Archive: https://registry.npmjs.org/pako/-/pako-1.0.11.tgz diff --git a/package-lock.json b/package-lock.json index 4e065d212e..1606244d90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "isomorphic-git": "1.38.7", "jsonc-parser": "3.3.1", "lossless-json": "4.3.1", + "openpgp": "6.3.2", "parse5": "8.0.1", "pino": "10.4.0", "playwright-core": "1.58.2", @@ -7871,6 +7872,24 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/openpgp": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/openpgp/-/openpgp-6.3.2.tgz", + "integrity": "sha512-wcZTzHz41LV8Y48zH/JlD1JT8YdNmpWOuMAjQ/podvvCwsjBEU37K3znJh4UQcj3hksE5z9TzvhbUbwzsGvlLQ==", + "license": "LGPL-3.0+", + "engines": { + "node": ">= 18.0.0", + "typescript": ">= 5.0.0" + }, + "peerDependencies": { + "@openpgp/web-stream-tools": "~0.3.0" + }, + "peerDependenciesMeta": { + "@openpgp/web-stream-tools": { + "optional": true + } + } + }, "node_modules/oxc-parser": { "version": "0.150.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.150.0.tgz", diff --git a/package.json b/package.json index 12a81a9867..305c0bd4fd 100644 --- a/package.json +++ b/package.json @@ -337,6 +337,7 @@ "isomorphic-git": "1.38.7", "jsonc-parser": "3.3.1", "lossless-json": "4.3.1", + "openpgp": "6.3.2", "parse5": "8.0.1", "pino": "10.4.0", "playwright-core": "1.58.2", diff --git a/scripts/hopper-demo-x11.py b/scripts/hopper-demo-x11.py index da2fe884f7..a2fc414891 100644 --- a/scripts/hopper-demo-x11.py +++ b/scripts/hopper-demo-x11.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Inspect or activate Hopper's version-pinned demo dialog on a private X display.""" +"""Inspect or activate Hopper's authenticated demo dialog on a private X display.""" from __future__ import annotations @@ -8,6 +8,7 @@ import ctypes import hashlib import json import os +import re from pathlib import Path import secrets import select @@ -18,12 +19,6 @@ import threading import time -# Keep this digest aligned with LinuxHopper.ts and rerun real-Hopper verification -# before accepting another vendor build. -SUPPORTED_HOPPER_SHA256 = { - "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded", - "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13", -} EXPECTED_SCREEN = (1280, 1024) DIALOG_TITLES = {"Registration", "Registration — Hopper Disassembler"} HOPPER_WINDOW_TITLE = "Hopper Disassembler" @@ -121,6 +116,7 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--probe", action="store_true") parser.add_argument("--owner-stdin", action="store_true") parser.add_argument("--hopper") + parser.add_argument("--expected-launcher-sha256") parser.add_argument("--socket") parser.add_argument( "--strategy", @@ -661,7 +657,11 @@ def run_probe(args: argparse.Namespace) -> dict[str, object]: def run_launch(args: argparse.Namespace) -> int: - if args.hopper is None or args.socket is None: + if ( + args.hopper is None or args.socket is None + or args.expected_launcher_sha256 is None + or re.fullmatch(r"[a-f0-9]{64}", args.expected_launcher_sha256) is None + ): raise AdapterFailure( INVALID_LAUNCH_COMMAND, "invalid_launch_command", @@ -673,7 +673,9 @@ def run_launch(args: argparse.Namespace) -> int: hopper = Path(args.hopper) socket = Path(args.socket) try: - supported = digest(hopper) in SUPPORTED_HOPPER_SHA256 + # The parent owns publisher/package interpretation. Recheck its admitted + # launcher bytes immediately before execution, preserving all X11 guards. + supported = digest(hopper) == args.expected_launcher_sha256 except OSError as error: raise AdapterFailure( INVALID_LAUNCH_COMMAND, @@ -684,7 +686,7 @@ def run_launch(args: argparse.Namespace) -> int: raise AdapterFailure( UNSUPPORTED_HOPPER_BUILD, "unsupported_hopper_build", - "unsupported Hopper binary", + "Hopper binary differs from the authenticated release", ) command = args.command[1:] if args.command[:1] == ["--"] else args.command if not command or Path(command[0]).resolve() != hopper.resolve(): diff --git a/scripts/verify-linux-setup-e2e.mjs b/scripts/verify-linux-setup-e2e.mjs index e08e8b1606..a0b247f870 100644 --- a/scripts/verify-linux-setup-e2e.mjs +++ b/scripts/verify-linux-setup-e2e.mjs @@ -13,6 +13,7 @@ import { promisify } from "node:util"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; import { assertCleanLinuxHopperRuntime } from "./lib/clean-linux-hopper-runtime.mjs"; +import { isUnsupportedHopperLaunchFailure } from "./verify-package-platform.mjs"; const execFileAsync = promisify(execFile); const verifierRun = createVerifierRun(); @@ -89,16 +90,24 @@ try { await appendFile(modifiedHopper, new Uint8Array([0])); await chmod(modifiedHopper, 0o700); try { - await execFileAsync(command, ["analyze", "/usr/bin/true"], { - env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper }, - timeout: 30_000, - }); + await execFileAsync( + command, + ["analyze", "/usr/bin/true", "--provider", "hopper", "--json"], + { + env: { ...process.env, HOPPER_LAUNCHER_PATH: modifiedHopper }, + timeout: 30_000, + }, + ); throw new Error("modified Hopper build was not rejected"); } catch (error) { - const output = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`; - if (!output.includes("unsupported_hopper_build")) + if ( + error?.code !== 1 || + typeof error.stdout !== "string" || + !isUnsupportedHopperLaunchFailure(JSON.parse(error.stdout)) + ) throw new Error( "modified Hopper build did not return its stable failure code", + { cause: error }, ); } } finally { diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index e57f7784b0..be2e81b865 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -1,5 +1,6 @@ import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { supportedClients } from "../dist/application/SupportedClients.js"; /** Create an isolated prefix, fake PATH, client configs, and environment. */ @@ -121,6 +122,14 @@ export const packageHopperEnvironment = (root, platform = process.platform) => { }; return { HOPPER_LAUNCHER_PATH: "/bin/sh", + // Feed availability must not turn this foreign-launcher rejection test into + // an unrelated networking failure. Successful vendor verification is real E2E coverage. + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(join(root, "tests/fixtures/hopperRejectionFeed.mjs")).href}`, + ] + .filter(Boolean) + .join(" "), HOPPER_LOADER_ARGS_JSON: JSON.stringify([ "-c", 'node_path=$1; shift; "$node_path" "$@"', diff --git a/scripts/verify-package-platform.mjs b/scripts/verify-package-platform.mjs index 0dd67aa86c..11cb8fd245 100644 --- a/scripts/verify-package-platform.mjs +++ b/scripts/verify-package-platform.mjs @@ -88,14 +88,29 @@ export async function verifyPackagePlatform({ } } -/** Require the declared unsupported build or an exact missing-Xvfb diagnostic, never arbitrary provider failure. */ +/** One owner for unsupported-launch evidence across package and real-Hopper verification. */ +export const isUnsupportedHopperLaunchFailure = (failure) => { + const details = failure?.details; + const unsupportedBuild = + details?.failure_code === "unsupported_hopper_build" && + details.exit_code === 72; + const publisherRejection = + details?.stage === "launch" && + details.launch_rejection?.boundary === "publisher_verification" && + details.launch_rejection.reason === "unsupported_hopper_build" && + details.exit_code === undefined && + details.launcher === undefined; + return ( + failure?.code === "provider_unavailable" && + (publisherRejection || unsupportedBuild) + ); +}; + +/** Require an exact pre-execution rejection, observed helper exit, or missing-Xvfb diagnostic. */ export const assertLinuxPackageProviderFailure = (execution) => { const failure = json(execution.stdout); const details = failure?.details; const diagnostic = details?.diagnostics; - const unsupportedBuild = - details?.failure_code === "unsupported_hopper_build" && - details.exit_code === 72; const missingDisplayDependency = details?.failure_code === "runtime_dependency_unavailable" && details.exit_code === 79 && @@ -108,7 +123,7 @@ export const assertLinuxPackageProviderFailure = (execution) => { if ( execution.status !== 1 || failure?.code !== "provider_unavailable" || - (!unsupportedBuild && !missingDisplayDependency) + (!isUnsupportedHopperLaunchFailure(failure) && !missingDisplayDependency) ) throw new Error( `packaged Linux Hopper verification did not fail closed: ${JSON.stringify(failure)}`, diff --git a/scripts/verify-real-hopper-fat.mjs b/scripts/verify-real-hopper-fat.mjs index 5b1776ccb8..930935ef7c 100644 --- a/scripts/verify-real-hopper-fat.mjs +++ b/scripts/verify-real-hopper-fat.mjs @@ -23,7 +23,7 @@ import { requireMcpOperationResult, } from "./lib/mcp-verifier-results.mjs"; import { snapshotHopperRuntime } from "./lib/real-hopper-cleanup.mjs"; -import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js"; +import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; import { parseConfig } from "../dist/config/parseConfig.js"; import { verifyHopperSourceBinding, diff --git a/scripts/verify-real-hopper.mjs b/scripts/verify-real-hopper.mjs index a82c1429a6..fbe2ff3757 100644 --- a/scripts/verify-real-hopper.mjs +++ b/scripts/verify-real-hopper.mjs @@ -12,7 +12,7 @@ import { requirePseudocode, } from "../dist/application/RealHopperAssertions.js"; import { HOPPER_PROVIDER_IDENTITY } from "../dist/hopper/HopperProviderCapabilities.js"; -import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/hopper/HopperTargetLease.js"; +import { HOPPER_TARGET_LEASE_DIRECTORY } from "../dist/process/HopperLease.js"; import { REA_WORKFLOW_PROVIDER } from "../dist/application/InvestigationProviders.js"; import { loadRealHopperFixtureTargets } from "./lib/real-hopper-fixture.mjs"; import { verifyHopperStringObjects } from "./lib/real-hopper-search.mjs"; diff --git a/src/application/Doctor.fixture.ts b/src/application/Doctor.fixture.ts index de23a6e3db..6d49e5ac90 100644 --- a/src/application/Doctor.fixture.ts +++ b/src/application/Doctor.fixture.ts @@ -1,5 +1,6 @@ import { CATALOG_IDENTITY } from "../catalogIdentity.js"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { ok } from "../domain/result.js"; import type { DoctorHost } from "./Doctor.js"; /** Healthy recording host for focused doctor and projection tests. */ @@ -14,7 +15,16 @@ export const createDoctorHostFixture = ( linuxDistribution: () => Promise.resolve(undefined), validTarget: (path) => Promise.resolve(path.includes("Hopper")), executable: (path) => Promise.resolve(path.includes("Hopper")), - supportedLinuxHopper: () => Promise.resolve(true), + inspectLinuxHopper: () => + Promise.resolve( + ok({ + sha256: "0".repeat(64), + version: "fixture", + primaryFingerprint: "0".repeat(40), + signingFingerprint: "1".repeat(40), + source: "signed_package_cache" as const, + }), + ), linuxDemoRuntimeCheck: () => Promise.resolve({ name: "hopper-demo-runtime", diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 2da77db087..2a2013c914 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -4,6 +4,8 @@ import { SUPPORTED_NODE_VERSION_PROSE } from "../domain/runtimeVersion.js"; import type { ClientRegistrationStatus } from "./ClientRegistrationStatus.js"; import { createDoctorHostFixture as host } from "./Doctor.fixture.js"; import { runDoctor } from "./Doctor.js"; +import { err } from "../domain/result.js"; +import { HopperReleaseError } from "./HopperReleaseFeed.js"; describe("doctor runtime check", () => { it("reports Node 25 as unsupported", async () => { @@ -617,12 +619,20 @@ describe("doctor Linux and Hopper discovery", () => { hopperSetupSupported: true, }), executable: (candidate) => Promise.resolve(candidate === path), - supportedLinuxHopper: () => Promise.resolve(false), + inspectLinuxHopper: () => + Promise.resolve( + err( + new HopperReleaseError( + "unsupported_hopper_build", + "Unverified fixture build", + ), + ), + ), }), ); expect( result.checks.find(({ name }) => name === "hopper-version")?.remediation, - ).toContain("Unset or update HOPPER_LAUNCHER_PATH"); + ).toContain("Check HOPPER_LAUNCHER_PATH"); }); }); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index dc761fad84..7918582d03 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -19,11 +19,14 @@ import { macHopperRenamedLauncherCandidates, } from "./MacHopperPaths.js"; import { - linuxHopperBinarySupported, + inspectLinuxHopperLauncher, linuxSharedLibrariesAvailable, readLinuxDistribution, type LinuxDistribution, } from "./LinuxHopper.js"; +import type { Result } from "../domain/result.js"; +import type { HopperReleaseError } from "./HopperReleaseFeed.js"; +import type { VerifiedLinuxHopperLauncher } from "./LinuxHopperPublisher.js"; import { readInstalledSkillIdentity, type InstalledSkillIdentity, @@ -101,7 +104,9 @@ export interface DoctorHost { /** Route-specific target admission details, when supplied by the host. */ inspectTarget?(path: string): Promise; executable(path: string): Promise; - supportedLinuxHopper(path: string): Promise; + inspectLinuxHopper( + path: string, + ): Promise>; linuxDemoRuntimeCheck(): Promise; brewHopperPath(): Promise; manualHopperPaths(): Promise; @@ -512,7 +517,7 @@ export const systemDoctorHost = ( ), linuxDemoRuntimeCheck: options.linuxDemoRuntimeCheck ?? uncomposedLinuxDemoRuntimeCheck, - supportedLinuxHopper: linuxHopperBinarySupported, + inspectLinuxHopper: (path) => inspectLinuxHopperLauncher(path, environment), async brewHopperPath() { return probeHomebrew(async (command) => { let caskroom: string; diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 070ce4809e..a2e34d9757 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -209,17 +209,34 @@ const optionalHopperVersionCheck = async ( hopperPath: string | undefined, ): Promise => { if (host.platform !== "linux" || hopperPath === undefined) return undefined; - return check( - "hopper-version", - await host.supportedLinuxHopper(hopperPath), - hopperPath, - { - remediation: unsupportedHopperRemediation( - host.configuredHopperPath === hopperPath, - ), - classification: "config_drift", - }, - ); + const inspected = await host.inspectLinuxHopper(hopperPath); + const buildCheck = check("hopper-version", inspected.ok, hopperPath, { + remediation: unsupportedHopperRemediation( + host.configuredHopperPath === hopperPath, + ), + classification: + !inspected.ok && + [ + "release_metadata", + "download", + "verification_unavailable", + "cancelled", + "cleanup_failed", + ].includes(inspected.error.reason) + ? "unknown" + : "config_drift", + }); + return { + ...buildCheck, + details: inspected.ok + ? { + reported_release_version: inspected.value.version, + launcher_sha256: inspected.value.sha256, + primary_fingerprint: inspected.value.primaryFingerprint, + source: inspected.value.source, + } + : { reason: inspected.error.reason, message: inspected.error.message }, + }; }; const optionalHopperDemoCheck = async ( @@ -297,8 +314,8 @@ const providerDoctorChecks = ( const unsupportedHopperRemediation = (configured: boolean): string => configured - ? "Unset or update HOPPER_LAUNCHER_PATH, install the Hopper build supported by this REA release, or update REA." - : "Install the Hopper build supported by this REA release, or update REA for a newer Hopper build."; + ? "Check HOPPER_LAUNCHER_PATH and its publisher verification evidence. Run rea setup to install and retain an authenticated official Hopper package for offline use." + : "Run rea setup to install and retain an authenticated official Hopper package for offline use."; const parseMajor = (version: string | undefined): number => Number.parseInt(version?.split(".")[0] ?? "0", 10); diff --git a/src/application/HopperDownload.ts b/src/application/HopperDownload.ts index 976ed610b4..dc8cf66084 100644 --- a/src/application/HopperDownload.ts +++ b/src/application/HopperDownload.ts @@ -4,9 +4,23 @@ export const HOPPER_DOWNLOAD_BYTES = 100_000_000; /** Bound observed response bytes before retaining them, including chunked bodies. */ export const downloadHopperPackage = async ( url: string, - options: { readonly signal?: AbortSignal } = {}, + options: { + readonly signal?: AbortSignal; + readonly maxBytes?: number; + readonly timeoutMs?: number; + } = {}, ): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }> => { - const deadline = AbortSignal.timeout(120_000); + const timeoutMs = options.timeoutMs ?? 120_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) + throw new RangeError("Invalid Hopper response time budget"); + const deadline = AbortSignal.timeout(timeoutMs); + const maxBytes = options.maxBytes ?? HOPPER_DOWNLOAD_BYTES; + if ( + !Number.isSafeInteger(maxBytes) || + maxBytes < 1 || + maxBytes > HOPPER_DOWNLOAD_BYTES + ) + throw new RangeError("Invalid Hopper response byte budget"); const response = await fetch(url, { headers: { accept: "application/json", "user-agent": "rea-installer" }, signal: @@ -17,7 +31,7 @@ export const downloadHopperPackage = async ( const refused = { ok: false, bytes: new Uint8Array() }; if ( !response.ok || - Number(response.headers.get("content-length")) > HOPPER_DOWNLOAD_BYTES + Number(response.headers.get("content-length")) > maxBytes ) { await response.body?.cancel(); return refused; @@ -30,7 +44,7 @@ export const downloadHopperPackage = async ( while (true) { const chunk = await reader.read(); if (chunk.done) return { ok: true, bytes: Buffer.concat(chunks, bytes) }; - if (chunk.value.byteLength > HOPPER_DOWNLOAD_BYTES - bytes) { + if (chunk.value.byteLength > maxBytes - bytes) { await reader.cancel(); return refused; } diff --git a/src/application/HopperReleaseFeed.test.ts b/src/application/HopperReleaseFeed.test.ts new file mode 100644 index 0000000000..2cb2f4e252 --- /dev/null +++ b/src/application/HopperReleaseFeed.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { parseLinuxHopperReleases } from "./HopperReleaseFeed.js"; + +const release = { + version: "99.2.1", + filename: + "https://www.hopperapp.com:443/downloader/public/Hopper-99.2.1-Linux-demo.deb", + file_length: "123", + file_hash: "a".repeat(40), + main_exe_hash: "b".repeat(64), + signature: "AQ==", + public: "1", + beta: "0", + published_at: "preserved vendor field", +}; + +describe("Linux Hopper release feed boundary", () => { + it("preserves future release identity and partial platform facts", () => { + const parsed = parseLinuxHopperReleases({ + "Ubuntu / Mint": release, + "OS X": { signature: "Sparkle DSA" }, + }); + expect(parsed).toMatchObject({ + ok: true, + value: { + deb: { ok: true, value: release }, + rpm: { ok: false }, + arch: { ok: false }, + }, + }); + if (!parsed.ok || !parsed.value.deb.ok) throw new Error("Missing release"); + expect(parsed.value.deb.value.filename).toBe(release.filename); + }); + + it.each([ + { signature: undefined }, + { signature: "AQ==garbage" }, + { main_exe_hash: "b".repeat(40) }, + { file_length: "100000001" }, + { file_length: "NaN" }, + { public: "0" }, + { beta: "1" }, + { + filename: + "https://other.example/downloader/public/Hopper-99.2.1-Linux-demo.deb", + }, + { filename: release.filename + "?token=caller" }, + { + filename: release.filename.replace("https://", "https://user:password@"), + }, + { filename: release.filename.replace(".deb", ".rpm") }, + { filename: release.filename.replace("demo.deb", "full.deb") }, + ])( + "rejects metadata outside the selected public package contract: %j", + (changes) => { + expect( + parseLinuxHopperReleases({ + "Ubuntu / Mint": { ...release, ...changes }, + }), + ).toMatchObject({ + ok: true, + value: { deb: { ok: false, error: { reason: "release_metadata" } } }, + }); + }, + ); + + it.each([null, [], "html error page"])( + "keeps malformed feed roots unknown", + (raw) => { + expect(parseLinuxHopperReleases(raw)).toMatchObject({ + ok: false, + error: { reason: "release_metadata" }, + }); + }, + ); +}); diff --git a/src/application/HopperReleaseFeed.ts b/src/application/HopperReleaseFeed.ts new file mode 100644 index 0000000000..a6c89abb0c --- /dev/null +++ b/src/application/HopperReleaseFeed.ts @@ -0,0 +1,125 @@ +import { z } from "zod"; + +import { err, ok, type Result } from "../domain/result.js"; +import { HOPPER_DOWNLOAD_BYTES } from "./HopperDownload.js"; + +/** Vendor endpoints, shared by installation and existing-build inspection. */ +export const HOPPER_RELEASES_URL = + "https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; +export const HOPPER_PUBLIC_KEY_URL = + "https://www.hopperapp.com/hopper-public-key.asc"; +export const HOPPER_DOWNLOAD_PREFIX = + "https://www.hopperapp.com:443/downloader/public/"; + +/** Stable publisher identity; signing subkeys are admitted through this primary. */ +export const HOPPER_LINUX_PRIMARY_FINGERPRINT = + "7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD"; + +export type LinuxPackageFamily = "deb" | "rpm" | "arch"; +const platforms = { + deb: "Ubuntu / Mint", + rpm: "Fedora", + arch: "Arch", +} as const; +const suffixes = { deb: ".deb", rpm: ".rpm", arch: ".pkg.tar.xz" } as const; + +export class HopperReleaseError extends Error { + readonly _tag = "HopperReleaseError"; + constructor( + readonly reason: + | "release_metadata" + | "download" + | "integrity" + | "publisher_verification_failed" + | "verification_unavailable" + | "unsupported_hopper_build" + | "cancelled" + | "cleanup_failed" + | "cache_missing", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = this._tag; + } +} + +const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); +const releaseSchema = z + .object({ + version: z.string().min(1), + filename: z.string().url(), + file_length: z + .string() + .regex(/^[1-9]\d*$/u) + .refine((text) => { + const count = Number(text); + return Number.isSafeInteger(count) && count <= HOPPER_DOWNLOAD_BYTES; + }), + file_hash: z.string().regex(/^[a-f0-9]{40}$/u), + main_exe_hash: sha256, + signature: z + .string() + .min(1) + .refine( + (text) => Buffer.from(text, "base64").toString("base64") === text, + ), + public: z.literal("1"), + beta: z.literal("0"), + }) + .passthrough(); + +/** Parsed once at the feed boundary; preserve the vendor's complete release row. */ +export type LinuxHopperRelease = z.infer; +export type LinuxHopperReleaseFeed = Readonly< + Record> +>; + +export const parseLinuxHopperRelease = ( + raw: unknown, + family: LinuxPackageFamily, +): Result => { + const parsed = releaseSchema.safeParse(raw); + if (!parsed.success) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper release omitted valid package identity or signature metadata.", + ), + ); + const url = new URL(parsed.data.filename); + if ( + url.origin !== "https://www.hopperapp.com" || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" || + !url.pathname.startsWith("/downloader/public/Hopper-") || + !url.pathname.endsWith(`-Linux-demo${suffixes[family]}`) + ) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper release is outside the official public Linux demo channel.", + ), + ); + return ok(parsed.data); +}; + +export const parseLinuxHopperReleases = ( + raw: unknown, +): Result => { + const feed = z.record(z.string(), z.unknown()).safeParse(raw); + if (!feed.success) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper releases response is not an object.", + ), + ); + return ok({ + deb: parseLinuxHopperRelease(feed.data[platforms.deb], "deb"), + rpm: parseLinuxHopperRelease(feed.data[platforms.rpm], "rpm"), + arch: parseLinuxHopperRelease(feed.data[platforms.arch], "arch"), + }); +}; diff --git a/src/application/LinuxHopper.test.ts b/src/application/LinuxHopper.test.ts index 3819ecfed4..e0ca5eef65 100644 --- a/src/application/LinuxHopper.test.ts +++ b/src/application/LinuxHopper.test.ts @@ -4,7 +4,6 @@ import { describe, expect, it } from "vitest"; import { formatCommandForDisplay, installLinuxHopper, - linuxHopperLauncherDigestSupported, linuxPackageManagerCommands, linuxSharedLibrariesAvailable, parseLinuxDistribution, @@ -18,6 +17,12 @@ import { type LinuxPackageInstallFailure, type LinuxPackageInstallOutcome, } from "./LinuxHopper.js"; +import { err, ok } from "../domain/result.js"; +import { + HopperReleaseError, + type LinuxHopperRelease, +} from "./HopperReleaseFeed.js"; +import type { VerifiedLinuxHopperPackage } from "./LinuxHopperPublisher.js"; describe("Linux package-manager command display", () => { it("preserves argument boundaries for paths with spaces and quotes", () => { @@ -43,6 +48,9 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { archiveOk = true; installSucceeds = true; cleanupFails = false; + signatureValid = true; + cacheWritable = true; + verifiedBeforeInstall = false; packageFailure: LinuxPackageInstallFailure = { command: "pacman -U --noconfirm /tmp/rea test/hopper.arch", exitCode: 1, @@ -57,6 +65,8 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { distribution = (): Promise => Promise.resolve(this.distributionValue); + release = (family: LinuxPackageFamily) => + Promise.resolve(ok(releasesFor(new Uint8Array([1, 2, 3]))[family])); download = (url: string): Promise => { this.downloads.push(url); return Promise.resolve({ ok: this.archiveOk, bytes: this.archive }); @@ -64,10 +74,52 @@ class RecordingLinuxHost implements LinuxHopperInstallHost { createTemporaryDirectory = (): Promise => Promise.resolve("/tmp/rea test"); writeArchive = (): Promise => Promise.resolve(); + verifyArchive = (family: LinuxPackageFamily, release: LinuxHopperRelease) => { + if ( + createHash("sha1").update(this.archive).digest("hex") !== + release.file_hash + ) + return Promise.resolve( + err(new HopperReleaseError("integrity", "Fixture byte mismatch")), + ); + if (!this.signatureValid) + return Promise.resolve( + err( + new HopperReleaseError( + "publisher_verification_failed", + "Fixture signature rejection", + ), + ), + ); + this.verifiedBeforeInstall = true; + return Promise.resolve( + ok({ + family, + release, + packageSha256: createHash("sha256").update(this.archive).digest("hex"), + publicKey: "fixture", + primaryFingerprint: "0".repeat(40), + signingFingerprint: "1".repeat(40), + }), + ); + }; + retainVerifiedArchive = (_verified: VerifiedLinuxHopperPackage) => + Promise.resolve( + this.cacheWritable + ? ok(undefined) + : err( + new HopperReleaseError( + "verification_unavailable", + "Fixture cache permission rejection", + ), + ), + ); installPackage = ( family: LinuxPackageFamily, archive: string, ): Promise => { + if (!this.verifiedBeforeInstall) + throw new Error("Package manager ran before publisher verification"); this.installed.push({ family, archive }); const outcome: LinuxPackageInstallOutcome = this.installSucceeds ? { status: "installed" } @@ -160,20 +212,6 @@ describe("Linux Hopper host classification", () => { }); describe("Linux Hopper installation", () => { - it.each([ - [ - "Debian and Arch", - "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded", - ], - ["RPM", "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13"], - ])("accepts the pinned %s launcher build", (_, digest) => { - expect(linuxHopperLauncherDigestSupported(digest)).toBe(true); - }); - - it("rejects an unverified launcher build", () => { - expect(linuxHopperLauncherDigestSupported("0".repeat(64))).toBe(false); - }); - it.each([ ["deb", "apt-get", ["xvfb", "xauth", "python3", "libx11-6", "libxtst6"]], [ @@ -239,6 +277,23 @@ describe("Linux Hopper package installation", () => { }); describe("Linux Hopper installation failure classification", () => { + it.each([ + ["signatureValid", "publisher_verification_failed"], + ["cacheWritable", "verification_unavailable"], + ] as const)( + "stops before privileged installation when %s fails", + async (field, reason) => { + const host = new RecordingLinuxHost(); + host[field] = false; + expect(await installFixture(host)).toMatchObject({ + status: "failed", + reason, + verificationFailure: { reason, message: expect.any(String) }, + }); + expect(host.installed).toEqual([]); + expect(host.cleaned).toEqual(["/tmp/rea test"]); + }, + ); it.each([ ["unsupported host", "unsupported_host"], ["package request failure", "download"], @@ -263,7 +318,7 @@ describe("Linux Hopper installation failure classification", () => { host.launcherStatusValue = "runtime_dependencies"; if (scenario === "unsupported build") host.launcherStatusValue = "unsupported_hopper_build"; - expect(await installFixture(host)).toEqual({ + expect(await installFixture(host)).toMatchObject({ status: "failed", reason, ...(scenario === "package manager failure" @@ -363,14 +418,16 @@ describe("Linux Hopper temporary cleanup ownership", () => { function releasesFor( bytes: Uint8Array, -): Record< - LinuxPackageFamily, - { filename: string; file_length: string; file_hash: string } -> { +): Record { const release = (extension: string) => ({ - filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`, + version: "future-fixture", + filename: `https://www.hopperapp.com:443/downloader/public/Hopper-future-fixture-Linux-demo.${extension}`, file_length: String(bytes.byteLength), file_hash: createHash("sha1").update(bytes).digest("hex"), + main_exe_hash: "0".repeat(64), + signature: "AQ==", + public: "1" as const, + beta: "0" as const, }); return { deb: release("deb"), @@ -379,7 +436,4 @@ function releasesFor( }; } -const installFixture = (host: RecordingLinuxHost) => - installLinuxHopper(host, { - releases: releasesFor(new Uint8Array([1, 2, 3])), - }); +const installFixture = (host: RecordingLinuxHost) => installLinuxHopper(host); diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts index b3503ae7eb..342fa20028 100644 --- a/src/application/LinuxHopper.ts +++ b/src/application/LinuxHopper.ts @@ -1,55 +1,37 @@ -import { createHash } from "node:crypto"; -import { createReadStream } from "node:fs"; import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileBoundedOutput } from "../process/ExecFileBoundedOutput.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; +import { downloadHopperPackage } from "./HopperDownload.js"; +import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { err, type Result } from "../domain/result.js"; import { - downloadHopperPackage, - HOPPER_DOWNLOAD_BYTES, -} from "./HopperDownload.js"; + HOPPER_RELEASES_URL, + HOPPER_PUBLIC_KEY_URL, + HOPPER_DOWNLOAD_PREFIX, + HOPPER_LINUX_PRIMARY_FINGERPRINT, + HopperReleaseError, + type LinuxHopperRelease, + type LinuxPackageFamily, +} from "./HopperReleaseFeed.js"; +import { + LinuxHopperPublisher, + retryLinuxHopperPublisherCleanup, + type VerifiedLinuxHopperPackage, + type VerifiedLinuxHopperLauncher, +} from "./LinuxHopperPublisher.js"; + +export type { LinuxPackageFamily } from "./HopperReleaseFeed.js"; /** Package-manager output retained per stream without stopping the command. */ const PACKAGE_MANAGER_OUTPUT_BYTES = 64 * 1024; -const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; -const SUPPORTED_HOPPER_SHA256 = new Set([ - "0294ced141cc373468ee22d8343e7dac41980cb05a937994ca81c9f09afe7ded", - "1339f9e58377442b0c6fcb0dfc3cec20d593cc557408521fad9a00dbc6b8da13", -]); -interface LinuxHopperRelease { - readonly filename: string; - readonly file_length: string; - readonly file_hash: string; -} -const SUPPORTED_RELEASES: Readonly< - Record -> = { - deb: { - filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.deb`, - file_length: "35755772", - file_hash: "e4f79dff602648a8ff4a88b773875b6bfac0dc65", - }, - rpm: { - filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.rpm`, - file_length: "32264475", - file_hash: "b9a65f2f583a386d440d4401d9140aef3bf305de", - }, - arch: { - filename: `${DOWNLOAD_PREFIX}Hopper-6.4.2-Linux-demo.pkg.tar.xz`, - file_length: "29405968", - file_hash: "92d780b61cab38eda95dc4112dee4fbeb0920f46", - }, -}; - -export type LinuxPackageFamily = "deb" | "rpm" | "arch"; /** Exact stable external effects disclosed before a Linux Hopper installation. */ export interface LinuxHopperInstallDisclosure { - readonly downloadUrl: string; - readonly expectedBytes: number; - readonly expectedSha1: string; + readonly networkOrigins: readonly string[]; + readonly integrity: string; readonly commands: readonly string[]; } @@ -58,11 +40,13 @@ export const linuxHopperInstallDisclosure = ( family: LinuxPackageFamily, isRoot: boolean, ): LinuxHopperInstallDisclosure => { - const release = SUPPORTED_RELEASES[family]; return { - downloadUrl: release.filename, - expectedBytes: Number(release.file_length), - expectedSha1: release.file_hash, + networkOrigins: [ + HOPPER_RELEASES_URL, + HOPPER_PUBLIC_KEY_URL, + HOPPER_DOWNLOAD_PREFIX, + ], + integrity: `Detached PGP signature · primary ${HOPPER_LINUX_PRIMARY_FINGERPRINT} · package byte identity and installed launcher SHA-256 from the official feed`, commands: linuxPackageManagerCommands( family, ``, @@ -133,6 +117,10 @@ export type LinuxPackageInstallOutcome = /** External effects required to install the official Linux package. */ export interface LinuxHopperInstallHost { distribution(): Promise; + release( + family: LinuxPackageFamily, + signal?: AbortSignal, + ): Promise>; download( url: string, options: { readonly signal?: AbortSignal }, @@ -143,8 +131,23 @@ export interface LinuxHopperInstallHost { family: LinuxPackageFamily, archive: string, ): Promise; - launcherStatus(path: string): Promise; + verifyArchive( + family: LinuxPackageFamily, + release: LinuxHopperRelease, + archive: string, + signal?: AbortSignal, + ): Promise>; + retainVerifiedArchive( + verified: VerifiedLinuxHopperPackage, + archive: string, + signal?: AbortSignal, + ): Promise>; + launcherStatus( + path: string, + expectedSha256: string, + ): Promise; cleanup(path: string): Promise; + close?(): Promise; } /** Structured outcome for every expected Linux installation failure. */ @@ -163,6 +166,9 @@ export type LinuxHopperInstallResult = | "launcher_missing" | "runtime_dependencies" | "unsupported_hopper_build" + | "publisher_verification_failed" + | "verification_unavailable" + | "cleanup_failed" | "cancelled"; /** * The failing package-manager command and its output. Present only when @@ -170,6 +176,8 @@ export type LinuxHopperInstallResult = * package manager said instead of assuming an authorization problem. */ readonly packageFailure?: LinuxPackageInstallFailure; + /** Publisher-owned failure, retained for the public setup diagnostic. */ + readonly verificationFailure?: HopperReleaseError; } & { readonly cleanupFailure?: LinuxHopperTemporaryCleanupFailure }); /** Failed removal of an owned temporary install directory. */ @@ -270,9 +278,6 @@ export const installLinuxHopper = async ( host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(process.env), options: { readonly signal?: AbortSignal; - readonly releases?: Readonly< - Record - >; } = {}, ): Promise => { if (options.signal?.aborted === true) @@ -284,16 +289,56 @@ export const installLinuxHopper = async ( let result: LinuxHopperInstallResult; try { result = await (async (): Promise => { - const release = (options.releases ?? SUPPORTED_RELEASES)[ - distribution.packageFamily - ]; + const selected = await host.release( + distribution.packageFamily, + options.signal, + ); + if (!selected.ok) + return { + status: "failed", + verificationFailure: selected.error, + reason: + selected.error.reason === "cancelled" + ? "cancelled" + : "release_metadata", + }; + const release = selected.value; const archiveDownload = await host.download(release.filename, options); if (!archiveDownload.ok) return { status: "failed", reason: "download" }; - if (!packageIntegrityMatches(archiveDownload.bytes, release)) - return { status: "failed", reason: "integrity" }; temporary = await host.createTemporaryDirectory(); const archive = join(temporary, `hopper.${distribution.packageFamily}`); await host.writeArchive(archive, archiveDownload.bytes); + const verified = await host.verifyArchive( + distribution.packageFamily, + release, + archive, + options.signal, + ); + if (!verified.ok) + return { + status: "failed", + verificationFailure: verified.error, + reason: + verified.error.reason === "cache_missing" + ? "verification_unavailable" + : verified.error.reason, + }; + const retained = await host.retainVerifiedArchive( + verified.value, + archive, + options.signal, + ); + if (!retained.ok) + return { + status: "failed", + verificationFailure: retained.error, + reason: + retained.error.reason === "cache_missing" + ? "verification_unavailable" + : retained.error.reason, + }; + if (options.signal?.aborted === true) + return { status: "failed", reason: "cancelled" }; const install = await host.installPackage( distribution.packageFamily, archive, @@ -305,7 +350,10 @@ export const installLinuxHopper = async ( packageFailure: install.failure, }; const launcherPath = "/opt/hopper/bin/Hopper"; - const launcherStatus = await host.launcherStatus(launcherPath); + const launcherStatus = await host.launcherStatus( + launcherPath, + release.main_exe_hash, + ); if (launcherStatus !== "ready") return { status: "failed", @@ -360,49 +408,82 @@ export const linuxHopperLauncherCandidates = ( /** Compose package-manager and linker checks from the selected caller environment. */ export const systemLinuxHopperInstallHost = ( environment: Readonly, -): LinuxHopperInstallHost => ({ - distribution: readLinuxDistribution, - download: downloadHopperPackage, - createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")), - writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }), - installPackage: (family, archive) => - installSystemPackage(family, archive, environment), - async launcherStatus(path) { - try { - await access(path); - const linked = await execFileOutput("ldd", [path], { env: environment }); - if (!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)) - return "runtime_dependencies"; - return (await linuxHopperBinarySupported(path)) - ? "ready" - : "unsupported_hopper_build"; - } catch (cause: unknown) { - // best-effort cleanup: optional launcher probing; unreadable means missing. - void cause; - return "missing"; - } - }, - cleanup: (path) => rm(path, { recursive: true, force: true }), -}); - -/** Match the Linux Hopper build validated by the packaged demo-dialog adapter. */ -export const linuxHopperBinarySupported = async ( - path: string, -): Promise => { - try { - const hash = createHash("sha256"); - for await (const chunk of createReadStream(path)) hash.update(chunk); - return linuxHopperLauncherDigestSupported(hash.digest("hex")); - } catch (cause: unknown) { - // best-effort cleanup: optional build probing; unreadable means unsupported. - void cause; - return false; - } +): LinuxHopperInstallHost => { + const publisher = systemLinuxHopperPublisher(environment); + return { + distribution: readLinuxDistribution, + release: (family, signal) => publisher.currentRelease(family, signal), + download: downloadHopperPackage, + createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")), + writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }), + installPackage: (family, archive) => + installSystemPackage(family, archive, environment), + verifyArchive: (family, release, archive, signal) => + publisher.verifyArchive(family, release, archive, signal), + retainVerifiedArchive: (verified, archive, signal) => + publisher.retainVerifiedArchive(verified, archive, signal), + async launcherStatus(path, expectedSha256) { + try { + await access(path); + const linked = await execFileOutput("ldd", [path], { + env: environment, + }); + if ( + !linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`) + ) + return "runtime_dependencies"; + return (await hashStableFile(path)).sha256 === expectedSha256 + ? "ready" + : "unsupported_hopper_build"; + } catch (cause: unknown) { + // best-effort cleanup: optional launcher probing; unreadable means missing. + void cause; + return "missing"; + } + }, + cleanup: (path) => rm(path, { recursive: true, force: true }), + close: async () => { + await publisher.close(); + await retryLinuxHopperPublisherCleanup(); + }, + }; }; -/** Match a launcher extracted from one of the pinned official Linux packages. */ -export const linuxHopperLauncherDigestSupported = (digest: string): boolean => - SUPPORTED_HOPPER_SHA256.has(digest); +export const systemLinuxHopperPublisher = ( + environment: Readonly, +): LinuxHopperPublisher => + new LinuxHopperPublisher( + environment, + async () => (await readLinuxDistribution())?.packageFamily, + ); + +/** Read-only authentication; persistent package retention belongs to approved setup. */ +export const inspectLinuxHopperLauncher = async ( + path: string, + environment: Readonly, + signal?: AbortSignal, +): Promise> => { + const publisher = systemLinuxHopperPublisher(environment); + const result = await publisher.inspectLauncher(path, signal); + try { + await publisher.close(); + await retryLinuxHopperPublisherCleanup(); + return result; + } catch (cause: unknown) { + return err( + new HopperReleaseError( + "cleanup_failed", + "Hopper publisher verification retains incomplete cleanup.", + { + cause: new AggregateError( + result.ok ? [cause] : [result.error, cause], + "Publisher verification outcomes", + ), + }, + ), + ); + } +}; /** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */ export const linuxSharedLibrariesAvailable = (output: string): boolean => @@ -492,14 +573,6 @@ export const linuxPackageManagerCommands = ( }); }; -const packageIntegrityMatches = ( - bytes: Uint8Array, - release: LinuxHopperRelease, -): boolean => - bytes.byteLength === Number(release.file_length) && - bytes.byteLength <= HOPPER_DOWNLOAD_BYTES && - createHash("sha1").update(bytes).digest("hex") === release.file_hash; - const packageFamilyFor = ( id: string, idLike: string | undefined, diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts new file mode 100644 index 0000000000..79f09729e3 --- /dev/null +++ b/src/application/LinuxHopperPublisher.ts @@ -0,0 +1,833 @@ +import { createHash, randomUUID } from "node:crypto"; +import { copyFile, lstat, mkdir, rename, writeFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; + +import { z } from "zod"; +import { + ArtifactResourceScope, + type ArtifactResourceOwner, +} from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; +import { verifyDetachedPgpSignature } from "../process/DetachedPgpSignature.js"; +import { execFileOutput } from "../process/ExecFileOutput.js"; +import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; +import { waitForAbortableDelay } from "../process/ProviderDeadline.js"; +import { + acquireHopperReleaseCacheLease, + type HopperTargetLease, +} from "../process/HopperLease.js"; +import { + downloadHopperPackage, + HOPPER_DOWNLOAD_BYTES, +} from "./HopperDownload.js"; +import { + HOPPER_LINUX_PRIMARY_FINGERPRINT, + HOPPER_PUBLIC_KEY_URL, + HOPPER_RELEASES_URL, + HopperReleaseError, + parseLinuxHopperRelease, + parseLinuxHopperReleases, + type LinuxHopperRelease, + type LinuxPackageFamily, +} from "./HopperReleaseFeed.js"; +import { withRegularFile } from "./RegularFileRead.js"; + +/** Metadata/key inspection budget, independent of the compressed package budget. */ +const RELEASE_METADATA_BYTES = 1024 * 1024; +/** Small feed/key requests must not consume the full package-download deadline. */ +const RELEASE_METADATA_TIMEOUT_MS = 10_000; +const cacheRecordSchema = z.object({ + format: z.literal("rea-signed-hopper-release-v1"), + family: z.enum(["deb", "rpm", "arch"]), + release: z.unknown(), +}); + +export interface VerifiedLinuxHopperPackage { + readonly release: LinuxHopperRelease; + readonly family: LinuxPackageFamily; + readonly packageSha256: string; + readonly publicKey: string; + readonly primaryFingerprint: string; + readonly signingFingerprint: string; +} + +export interface VerifiedLinuxHopperLauncher { + readonly sha256: string; + /** Display value from release metadata; the package signature binds bytes, not this row. */ + readonly version: string; + readonly primaryFingerprint: string; + readonly signingFingerprint: string; + readonly source: + | "signed_package_cache" + | "signed_vendor_release" + | "verified_process_cache"; +} + +/** Retain failed ephemeral cleanup independently of the caller's lifetime. */ +const pendingCleanup = new Set(); +/** Immutable cryptographic facts, bounded to eight packages for this process lifetime. */ +const packageProofs = new Map< + string, + Pick +>(); +const liveLauncherProofs = new Map(); +const retainProof = (cache: Map, key: string, proof: T): void => { + cache.delete(key); + cache.set(key, proof); + const oldest = cache.keys().next().value; + if (cache.size > 8 && oldest !== undefined) cache.delete(oldest); +}; + +/** One owner for the selected cache location, shared with the approval plan. */ +export const linuxHopperReleaseCacheDirectory = ( + environment: Readonly, +): Result => { + try { + const supplied = environment.XDG_CACHE_HOME || undefined; + if (supplied !== undefined && !isAbsolute(supplied)) + return err( + new HopperReleaseError( + "verification_unavailable", + "XDG_CACHE_HOME must be an absolute filesystem path.", + ), + ); + return ok( + join( + supplied ?? + join(homeDirectoryFromEnvironment(environment, "linux"), ".cache"), + "rea", + "hopper-releases", + ), + ); + } catch (cause: unknown) { + return err( + new HopperReleaseError( + "verification_unavailable", + "The Hopper release cache location could not be resolved.", + { cause }, + ), + ); + } +}; + +/** Authenticated package facts shared by setup, doctor, and the launch boundary. */ +export class LinuxHopperPublisher { + readonly #resources = new ArtifactResourceScope(); + #feed: ReturnType | undefined; + + constructor( + readonly environment: Readonly, + readonly packageFamily: () => Promise, + ) {} + + get cacheRoot(): string { + const location = linuxHopperReleaseCacheDirectory(this.environment); + if (!location.ok) throw location.error; + return location.value; + } + + async currentRelease( + family: LinuxPackageFamily, + signal?: AbortSignal, + ): Promise> { + try { + signal?.throwIfAborted(); + if (this.#feed === undefined) { + const response = await downloadHopperPackage(HOPPER_RELEASES_URL, { + ...(signal === undefined ? {} : { signal }), + maxBytes: RELEASE_METADATA_BYTES, + timeoutMs: RELEASE_METADATA_TIMEOUT_MS, + }); + if (!response.ok) + throw new HopperReleaseError( + "release_metadata", + "The official Hopper release feed is unavailable.", + ); + this.#feed = parseLinuxHopperReleases( + JSON.parse(decodeUtf8(response.bytes)), + ); + } + if (!this.#feed.ok) return this.#feed; + return this.#feed.value[family]; + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "release_metadata")); + } + } + + async verifyArchive( + family: LinuxPackageFamily, + release: LinuxHopperRelease, + archive: string, + signal?: AbortSignal, + suppliedPublicKey?: string, + ): Promise> { + try { + const bytes = await this.#read(archive, HOPPER_DOWNLOAD_BYTES, signal); + if ( + bytes.length !== Number(release.file_length) || + createHash("sha1").update(bytes).digest("hex") !== release.file_hash + ) + return err( + new HopperReleaseError( + "integrity", + "The Hopper package does not match the selected release bytes.", + ), + ); + let publicKey = suppliedPublicKey; + if (publicKey === undefined) { + const response = await downloadHopperPackage(HOPPER_PUBLIC_KEY_URL, { + ...(signal === undefined ? {} : { signal }), + maxBytes: RELEASE_METADATA_BYTES, + timeoutMs: RELEASE_METADATA_TIMEOUT_MS, + }); + if (!response.ok) + throw new HopperReleaseError( + "verification_unavailable", + "The official Hopper public key is unavailable.", + ); + publicKey = decodeUtf8(response.bytes); + } + const packageSha256 = createHash("sha256").update(bytes).digest("hex"); + // Every read still validates file identity, byte bounds and selected metadata. + // Reuse only proof of the exact signed bytes/key/signature/payload interpretation. + const proofKey = createHash("sha256") + .update( + JSON.stringify([ + family, + packageSha256, + publicKey, + release.signature, + release.main_exe_hash, + ]), + ) + .digest("hex"); + signal?.throwIfAborted(); + const existingProof = packageProofs.get(proofKey); + if (existingProof !== undefined) + return ok({ + release, + family, + packageSha256, + publicKey, + ...existingProof, + }); + const verified = await verifyDetachedPgpSignature( + bytes, + Buffer.from(release.signature, "base64"), + publicKey, + HOPPER_LINUX_PRIMARY_FINGERPRINT, + signal, + ); + if (!verified.ok) + return err( + new HopperReleaseError( + verified.error.reason === "cancelled" + ? "cancelled" + : "publisher_verification_failed", + verified.error.message, + { cause: verified.error }, + ), + ); + const mainExeHash = await this.#withTemporary(async (root) => { + // Extract only after authentication, from this operation's private byte snapshot. + const snapshot = join(root, "package"); + await writeFile(snapshot, bytes, { flag: "wx", mode: 0o600 }); + return this.#packageLauncherHash(family, snapshot, root, signal); + }); + if (mainExeHash !== release.main_exe_hash) + return err( + new HopperReleaseError( + "integrity", + `The signed ${family} package launcher SHA-256 ${mainExeHash} differs from the release main_exe_hash ${release.main_exe_hash}.`, + ), + ); + retainProof(packageProofs, proofKey, verified.value); + return ok({ + release, + family, + packageSha256, + publicKey, + ...verified.value, + }); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + /** Durable evidence contains the signed bytes; an editable digest receipt cannot authorize a build. */ + async retainVerifiedArchive( + verified: VerifiedLinuxHopperPackage, + archive: string, + signal?: AbortSignal, + ): Promise> { + try { + signal?.throwIfAborted(); + await mkdir(this.cacheRoot, { recursive: true, mode: 0o700 }); + const parent = await lstat(this.cacheRoot); + if ( + !parent.isDirectory() || + parent.isSymbolicLink() || + parent.uid !== process.getuid?.() + ) + throw new HopperReleaseError( + "verification_unavailable", + "The Hopper release cache is not a caller-owned directory.", + ); + const target = join(this.cacheRoot, verified.release.main_exe_hash); + const lease = await this.#acquireCacheLease( + verified.release.main_exe_hash, + signal, + ); + await this.#withOwnedResource( + { + kind: "lease", + resource: lease.socketPath ?? "hopper-release-cache-lease", + lease, + }, + async () => { + signal?.throwIfAborted(); + await this.#withTemporary(async (stage) => { + await copyFile(archive, join(stage, "archive")); + if ( + (await hashStableFile(join(stage, "archive"), signal)).sha256 !== + verified.packageSha256 + ) + throw new HopperReleaseError( + "integrity", + "The verified package changed before cache retention.", + ); + await writeFile(join(stage, "public-key.asc"), verified.publicKey, { + flag: "wx", + mode: 0o600, + }); + await writeFile( + join(stage, "release.json"), + JSON.stringify({ + format: "rea-signed-hopper-release-v1", + family: verified.family, + release: verified.release, + }) + "\n", + { flag: "wx", mode: 0o600 }, + ); + await this.#publishCacheStage( + stage, + target, + verified.release.main_exe_hash, + signal, + ); + }, this.cacheRoot); + }, + ); + return ok(undefined); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + async #publishCacheStage( + stage: string, + target: string, + digest: string, + signal?: AbortSignal, + ): Promise { + const existing = await this.#cachedPackage(digest, signal); + if (existing.ok) return; + if (existing.error.reason === "cache_missing") { + await rename(stage, target); + return; + } + if ( + ![ + "integrity", + "release_metadata", + "publisher_verification_failed", + ].includes(existing.error.reason) + ) + throw existing.error; + signal?.throwIfAborted(); + const previous = await lstat(target); + if ( + !previous.isDirectory() || + previous.isSymbolicLink() || + previous.uid !== process.getuid?.() + ) + throw existing.error; + // Only approved retention under the publication lease may repair an entry. + // Preserve malformed evidence without following links or deleting content. + const backup = `${target}.backup-${randomUUID()}`; + await rename(target, backup); + try { + const moved = await lstat(backup); + if (moved.dev !== previous.dev || moved.ino !== previous.ino) + throw new Error("The Hopper cache entry changed during repair."); + await rename(stage, target); + } catch (cause: unknown) { + const replacement = await this.#cachedPackage(digest, signal); + if (replacement.ok) return; + await this.#failedCachePublication( + target, + backup, + [cause, replacement.error], + replacement.error.reason === "cleanup_failed", + ); + } + } + + async #acquireCacheLease( + digest: string, + signal?: AbortSignal, + ): Promise { + const deadline = performance.now() + 120_000; + const runId = randomUUID(); + let delay = 25; + while (true) { + signal?.throwIfAborted(); + const acquired = await acquireHopperReleaseCacheLease({ + cacheDirectory: this.cacheRoot, + launcherSha256: digest, + runId, + }); + if (acquired.acquired) return acquired.lease; + const remaining = deadline - performance.now(); + if (remaining <= 0) + throw new HopperReleaseError( + "verification_unavailable", + "Another approved Hopper setup still owns this release cache. Retry after it finishes.", + ); + await waitForAbortableDelay(Math.min(delay, remaining), signal); + delay = Math.min(delay * 2, 200); + } + } + + async #failedCachePublication( + target: string, + backup: string, + causes: unknown[], + cleanupFailed = false, + ): Promise { + let preserved = backup; + try { + // Under the publication lease, restore the previous directory on failure. + // POSIX rename cannot replace another nonempty evidence directory. + await rename(backup, target); + preserved = target; + } catch (cause: unknown) { + causes.push(cause); + } + throw new HopperReleaseError( + cleanupFailed ? "cleanup_failed" : "verification_unavailable", + `The Hopper cache repair did not finish; previous evidence is preserved at ${preserved}. Retry approved setup.`, + { + cause: new AggregateError( + causes, + "Hopper cache publication and recovery outcomes", + ), + }, + ); + } + + async inspectLauncher( + path: string, + signal?: AbortSignal, + ): Promise> { + try { + const observed = await hashStableFile(path, signal); + const cached = await this.#cachedPackage(observed.sha256, signal); + if (cached.ok) { + if ((await hashStableFile(path, signal)).sha256 !== observed.sha256) + return err( + new HopperReleaseError( + "integrity", + "The Hopper launcher changed during publisher verification.", + ), + ); + return ok(launcherIdentity(cached.value, "signed_package_cache")); + } + if (cached.error.reason !== "cache_missing") return cached; + signal?.throwIfAborted(); + const processProof = liveLauncherProofs.get(observed.sha256); + if (processProof !== undefined) + return ok({ ...processProof, source: "verified_process_cache" }); + const family = await this.packageFamily(); + if (family === undefined) + return err( + new HopperReleaseError( + "verification_unavailable", + "No native Linux package family is available for Hopper publisher verification.", + ), + ); + const selected = await this.currentRelease(family, signal); + if (!selected.ok) return selected; + if (selected.value.main_exe_hash !== observed.sha256) + return err( + new HopperReleaseError( + "unsupported_hopper_build", + "The selected Hopper launcher does not match an authenticated cached release or the official current Linux demo.", + ), + ); + const verified = await this.#withTemporary(async (root) => { + const response = await downloadHopperPackage( + selected.value.filename, + signal === undefined ? {} : { signal }, + ); + if (!response.ok) + throw new HopperReleaseError( + "download", + "The official Hopper package is unavailable for publisher verification.", + ); + const archive = join(root, "archive"); + await writeFile(archive, response.bytes, { flag: "wx", mode: 0o600 }); + const verified = await this.verifyArchive( + family, + selected.value, + archive, + signal, + ); + if (!verified.ok) throw verified.error; + return verified.value; + }); + if ((await hashStableFile(path, signal)).sha256 !== observed.sha256) + return err( + new HopperReleaseError( + "integrity", + "The Hopper launcher changed during publisher verification.", + ), + ); + const identity = launcherIdentity(verified, "signed_vendor_release"); + retainProof(liveLauncherProofs, observed.sha256, identity); + return ok(identity); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + async close(): Promise { + try { + await this.#resources.close(); + pendingCleanup.delete(this.#resources); + } catch (cause: unknown) { + pendingCleanup.add(this.#resources); + throw cause; + } + } + + async #cachedPackage( + digest: string, + signal?: AbortSignal, + ): Promise> { + try { + const root = join(this.cacheRoot, digest); + try { + const directory = await lstat(root); + if (!directory.isDirectory() || directory.isSymbolicLink()) + throw new HopperReleaseError( + "integrity", + "The retained Hopper cache entry is not a regular directory.", + ); + } catch (cause: unknown) { + if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") { + return err( + new HopperReleaseError( + "cache_missing", + "No retained signed Hopper package identifies this launcher.", + { cause }, + ), + ); + } + throw cause; + } + // Distinguish proven malformed/missing evidence from unavailable reads or + // inspection tools. Only the former authorize approved cache replacement. + for (const name of ["release.json", "public-key.asc", "archive"]) + await this.#assertCacheFile(join(root, name)); + const metadata = await this.#read( + join(root, "release.json"), + RELEASE_METADATA_BYTES, + signal, + ); + let value: unknown; + try { + value = JSON.parse(decodeUtf8(metadata)); + } catch (cause: unknown) { + throw new HopperReleaseError( + "release_metadata", + "The retained Hopper package metadata is malformed.", + { cause }, + ); + } + const parsed = cacheRecordSchema.safeParse(value); + if (!parsed.success) + throw new HopperReleaseError( + "release_metadata", + "The retained Hopper package metadata is malformed.", + ); + const release = parseLinuxHopperRelease( + parsed.data.release, + parsed.data.family, + ); + if (!release.ok) return release; + if (release.value.main_exe_hash !== digest) + throw new HopperReleaseError( + "integrity", + "The retained Hopper metadata does not identify this launcher.", + ); + const keyBytes = await this.#read( + join(root, "public-key.asc"), + RELEASE_METADATA_BYTES, + signal, + ); + let key: string; + try { + key = decodeUtf8(keyBytes); + } catch (cause: unknown) { + throw new HopperReleaseError( + "publisher_verification_failed", + "The retained Hopper public key is malformed.", + { cause }, + ); + } + return this.verifyArchive( + parsed.data.family, + release.value, + join(root, "archive"), + signal, + key, + ); + } catch (cause: unknown) { + return err(releaseFailure(cause, signal, "verification_unavailable")); + } + } + + async #assertCacheFile(path: string): Promise { + try { + const stats = await lstat(path); + if (!stats.isFile() || stats.isSymbolicLink()) + throw new HopperReleaseError( + "integrity", + `Required Hopper evidence is not a regular file: ${path}.`, + ); + } catch (cause: unknown) { + if (cause instanceof Error && Reflect.get(cause, "code") === "ENOENT") + throw new HopperReleaseError( + "integrity", + `Required Hopper evidence is missing: ${path}.`, + { cause }, + ); + throw cause; + } + } + + async #packageLauncherHash( + family: LinuxPackageFamily, + archive: string, + root: string, + signal?: AbortSignal, + ): Promise { + const commandOptions = { + env: this.environment, + signal, + timeout: 120_000, + stopSignal: "SIGKILL" as const, + maxBuffer: RELEASE_METADATA_BYTES, + }; + if (family === "rpm") { + const output = await execFileOutput( + "rpm", + [ + "-qp", + "--nosignature", + "--nodigest", + "--qf", + "%{FILEDIGESTALGO}\n[%{FILENAMES}\t%{FILEDIGESTS}\n]", + archive, + ], + commandOptions, + ); + const rows = output.stdout.split("\n"); + if (rows.shift() !== "8") + throw new HopperReleaseError( + "integrity", + "The signed RPM header does not use SHA-256 file digests.", + ); + const matches = rows.filter((row) => + row.startsWith("/opt/hopper/bin/Hopper\t"), + ); + const digest = matches[0]?.split("\t")[1]; + if ( + matches.length !== 1 || + digest === undefined || + !/^[a-f0-9]{64}$/u.test(digest) + ) + throw new HopperReleaseError( + "integrity", + "The signed RPM header has no unique Hopper launcher digest.", + ); + return digest; + } + const extracted = join(root, "extracted"); + await mkdir(extracted, { mode: 0o700 }); + if (family === "deb") + await execFileOutput( + "dpkg-deb", + ["--extract", archive, extracted], + commandOptions, + ); + else + await execFileOutput( + "tar", + ["-xf", archive, "-C", extracted, "--", "opt/hopper/bin/Hopper"], + commandOptions, + ); + return ( + await hashStableFile( + join(extracted, "opt", "hopper", "bin", "Hopper"), + signal, + ) + ).sha256; + } + + #read(path: string, limit: number, signal?: AbortSignal): Promise { + return withRegularFile( + path, + async (handle, stats) => { + if (stats.size > limit) + throw new HopperReleaseError( + "integrity", + "Retained Hopper evidence exceeds its resource byte budget.", + ); + const bytes = await readBoundedFileBytes(handle, stats.size, signal); + if (bytes === undefined) + throw new HopperReleaseError( + "integrity", + "Retained Hopper evidence changed during inspection.", + ); + return bytes; + }, + { signal, resources: this.#resources, symlinks: "reject" }, + ); + } + + async #withTemporary( + operation: (path: string) => Promise, + parent?: string, + ): Promise { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-hopper-publisher-", + ...(parent === undefined ? {} : { parent }), + }); + return this.#withOwnedResource( + { + kind: "private-runtime-root", + resource: root.path, + root, + }, + () => operation(root.path), + ); + } + + async #withOwnedResource( + owner: ArtifactResourceOwner, + operation: () => Promise, + ): Promise { + let outcome: + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly cause: unknown }; + try { + outcome = { ok: true, value: await operation() }; + } catch (cause: unknown) { + outcome = { ok: false, cause }; + } + const cleanup = await this.#resources.release(owner); + if (cleanup.kind === "failed") { + pendingCleanup.add(this.#resources); + const causes = outcome.ok + ? [cleanup.cause] + : [outcome.cause, cleanup.cause]; + throw new HopperReleaseError( + "cleanup_failed", + `Hopper publisher verification retained cleanup ownership for ${owner.resource}.`, + { + cause: new AggregateError( + causes, + "Publisher inspection and cleanup outcomes", + ), + }, + ); + } + if (!outcome.ok) throw outcome.cause; + return outcome.value; + } +} + +/** Retry only resources retained by this producer after failed cleanup. */ +export const retryLinuxHopperPublisherCleanup = async (): Promise => { + const causes: unknown[] = []; + const observations = []; + for (const scope of pendingCleanup) { + try { + await scope.close(); + pendingCleanup.delete(scope); + } catch (cause: unknown) { + causes.push(cause); + observations.push( + ArtifactReaderFailure.cleanupObservation( + cause, + "hopper-publisher-verification", + ), + ); + } + } + if (causes.length > 0) { + const cleanup = { + reason: observations.map(({ reason }) => reason).join("; "), + resources: [ + ...new Set(observations.flatMap(({ resources }) => resources)), + ], + }; + throw new ArtifactReaderFailure( + "unavailable", + `Hopper publisher cleanup remains incomplete: ${cleanup.resources.join(", ")}`, + { + cause: new AggregateError(causes, "Hopper publisher cleanup failures"), + cleanup, + }, + ); + } +}; + +const decodeUtf8 = (bytes: Uint8Array): string => + new TextDecoder("utf-8", { fatal: true }).decode(bytes); +const releaseFailure = ( + cause: unknown, + signal: AbortSignal | undefined, + fallback: HopperReleaseError["reason"], +): HopperReleaseError => + cause instanceof HopperReleaseError && cause.reason === "cleanup_failed" + ? cause + : signal?.aborted === true + ? new HopperReleaseError( + "cancelled", + "Hopper publisher verification was cancelled.", + { cause }, + ) + : cause instanceof HopperReleaseError + ? cause + : new HopperReleaseError( + fallback, + "Hopper publisher verification could not complete.", + { cause }, + ); +const launcherIdentity = ( + verified: VerifiedLinuxHopperPackage, + source: VerifiedLinuxHopperLauncher["source"], +): VerifiedLinuxHopperLauncher => ({ + sha256: verified.release.main_exe_hash, + version: verified.release.version, + primaryFingerprint: verified.primaryFingerprint, + signingFingerprint: verified.signingFingerprint, + source, +}); diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index b7137b9b87..22e9310e4e 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -5,6 +5,7 @@ import type { SetupProgressEvent } from "./SetupTypes.js"; import { FakeSetupHost, options } from "./Setup.fixture.js"; import { SetupSkillInstallError } from "./SetupSkill.js"; import { err, ok } from "../domain/result.js"; +import { HopperReleaseError } from "./HopperReleaseFeed.js"; const linkedSkillFailure = () => new SetupSkillInstallError({ @@ -16,6 +17,44 @@ const linkedSkillFailure = () => cause: undefined, }); +describe("Linux publisher cache approval", () => { + it.each([true, false])( + "discloses the selected cache or blocks an unresolved path before approval: %s", + async (valid) => { + const host = Object.assign(new FakeSetupHost("linux"), { + linuxHopperReleaseCache: valid + ? ok("/caller/cache/rea/hopper-releases") + : err( + new HopperReleaseError( + "verification_unavailable", + "Invalid selected cache path", + ), + ), + }); + host.distribution = { + id: "ubuntu", + versionId: "24.04", + packageFamily: "deb", + hopperSetupSupported: true, + }; + const result = await runSetup(options(false, true), host); + if (valid) + expect( + result.plannedActions.find(({ kind }) => kind === "install_hopper") + ?.detail, + ).toContain("/caller/cache/rea/hopper-releases/"); + else + expect(result).toMatchObject({ + status: "needs_human", + plannedActions: [], + }); + expect(host.hopperInstalls).toBe(0); + expect(host.configurations).toBe(0); + expect(host.skillInstalls).toBe(0); + }, + ); +}); + describe("selected skill admission", () => { it.each(["codex", "shared"] as const)( "keeps %s choices when an unselected client is linked", diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index 2ae65a0487..0840385e4e 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -20,8 +20,10 @@ import { LinuxHopperTemporaryCleanupOwner, readLinuxDistribution, systemLinuxHopperInstallHost, + type LinuxHopperInstallHost, } from "./LinuxHopper.js"; import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js"; +import { linuxHopperReleaseCacheDirectory } from "./LinuxHopperPublisher.js"; import { clientEvidencePaths, supportedClients, @@ -156,6 +158,12 @@ export const systemSetupHost = ( return { platform, homeDirectory, + ...(platform === "linux" + ? { + linuxHopperReleaseCache: + linuxHopperReleaseCacheDirectory(environment), + } + : {}), skillDestinations: (clientIds) => skillDestinations(homeDirectory, clientIds, environment, platform), ...setupRegistration(platform, homeDirectory, environment), @@ -164,7 +172,7 @@ export const systemSetupHost = ( linuxDistribution: readLinuxDistribution, close: () => closeSetupResources(resources, macHopperHost, doctorHost, () => - pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup), + closeLinuxHopperInstallHost(linuxHopperHost, pendingLinuxHopperCleanup), ), initialSetupState: async ( scope?: DoctorScope, @@ -206,9 +214,19 @@ export const systemSetupHost = ( const result = await installLinuxHopper(linuxHopperHost); if (result.cleanupFailure !== undefined) pendingLinuxHopperCleanup.retain(result.cleanupFailure); - return result.status === "installed" - ? { status: "installed", launcherPath: result.launcherPath } - : setupInstallFailure(result.reason, result.packageFailure); + if (result.status === "installed") + return { status: "installed", launcherPath: result.launcherPath }; + const failure = setupInstallFailure( + result.reason, + result.packageFailure, + "linux", + ); + return result.verificationFailure !== undefined + ? { + ...failure, + remediation: `${result.verificationFailure.message} ${failure.remediation}`, + } + : failure; } if (macHopperHost === undefined) return setupInstallFailure("unsupported_host"); @@ -425,6 +443,23 @@ const configurationCleanupError = ( const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); +const closeLinuxHopperInstallHost = async ( + host: LinuxHopperInstallHost, + temporaryOwner: LinuxHopperTemporaryCleanupOwner, +): Promise => { + const reasons: string[] = []; + const temporary = await temporaryOwner.retry(host.cleanup); + if (temporary !== undefined) reasons.push(temporary); + try { + await host.close?.(); + } catch (cause: unknown) { + reasons.push( + `Linux Hopper publisher cleanup failed: ${errorMessage(cause)}`, + ); + } + return reasons.length === 0 ? undefined : reasons.join("; "); +}; + /** Detect supported agents from their config files or stable installation markers. */ export const detectClients = async ( home: string, diff --git a/src/application/SetupInstallFailure.ts b/src/application/SetupInstallFailure.ts index 635918ca1e..6bb825054f 100644 --- a/src/application/SetupInstallFailure.ts +++ b/src/application/SetupInstallFailure.ts @@ -1,4 +1,5 @@ import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js"; +import { HOPPER_LINUX_PRIMARY_FINGERPRINT } from "./HopperReleaseFeed.js"; import type { LinuxPackageInstallFailure } from "./LinuxHopper.js"; export type SetupFailureCode = @@ -50,6 +51,8 @@ export type HopperInstallFailureReason = | "mount" | "bundle" | "publisher_verification_failed" + | "verification_unavailable" + | "cleanup_failed" | "copy"; const normalizePackageOutput = (output: string): string => @@ -99,6 +102,7 @@ const packageManagerRemediation = ( export const setupInstallFailure = ( reason: HopperInstallFailureReason, packageFailure?: LinuxPackageInstallFailure, + platform: NodeJS.Platform = "darwin", ): SetupHopperInstallFailure => { switch (reason) { case "unsupported_host": @@ -139,7 +143,7 @@ export const setupInstallFailure = ( status: "failed", code: "unsupported_hopper_build", remediation: - "The installed Hopper launcher does not match the build supported by this REA release. Unset HOPPER_LAUNCHER_PATH, reinstall through rea setup, or update REA.", + "The installed Hopper launcher does not match the authenticated vendor package. Check HOPPER_LAUNCHER_PATH and reinstall the official Hopper demo through rea setup.", }; case "cancelled": return { @@ -173,7 +177,24 @@ export const setupInstallFailure = ( return { status: "failed", code: "publisher_verification_failed", - remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`, + remediation: + platform === "linux" + ? `The Hopper package signature did not verify under the pinned primary PGP fingerprint ${HOPPER_LINUX_PRIMARY_FINGERPRINT}; the package manager was not run. Download the official package and retry setup.` + : `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`, + }; + case "verification_unavailable": + return { + status: "failed", + code: "publisher_verification_failed", + remediation: + "REA could not authenticate or retain the signed Hopper package. Check vendor access, native package inspection tools and caller-owned cache permissions, then retry setup.", + }; + case "cleanup_failed": + return { + status: "failed", + code: "cleanup_failed", + remediation: + "Hopper publisher verification retained temporary cleanup ownership. Retry cleanup before running setup again.", }; case "copy": return { diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 682eebbc46..3a7b3d0f1d 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -161,6 +161,14 @@ export const planSetupActions = async (input: { const client = byId.get(id); return client === undefined ? [] : [client]; }); + const hopperCache = input.host.linuxHopperReleaseCache; + if (input.discovery.installHopper && hopperCache?.ok === false) + return { + plannedActions: [], + selectedClients, + clientsToConfigure: [], + blocker: hopperCache.error.message, + }; if ( selectedClients.length > 0 && input.host.registrationCommandBlocker !== undefined @@ -199,6 +207,9 @@ export const planSetupActions = async (input: { const plannedActions = setupPlan({ platform: input.host.platform, homeDirectory: input.host.homeDirectory, + ...(hopperCache?.ok === true + ? { hopperCacheDirectory: hopperCache.value } + : {}), installHopper: input.discovery.installHopper, installSkill: input.installSkill, skillClientIds: input.skillClientIds, @@ -264,6 +275,7 @@ const plannedClientWrites = ( const setupPlan = (input: { readonly platform: NodeJS.Platform; readonly homeDirectory: string; + readonly hopperCacheDirectory?: string; readonly installHopper: boolean; readonly installSkill: boolean; readonly skillClientIds: readonly string[]; @@ -289,7 +301,7 @@ const setupPlan = (input: { : "system package manager", detail: input.platform === "linux" - ? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session." + ? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/ for offline verification. Preserve replaced damaged evidence beside it as .backup-. Install Hopper plus its Xvfb demo-session dependencies. REA uses a private display and selects Hopper's offered demo mode for each analysis session.` : "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.", external: true, operation: "install" as const, @@ -346,9 +358,9 @@ const linuxDisclosure = (family: LinuxPackageFamily) => { process.getuid?.() === 0, ); return { - networkOrigins: [disclosure.downloadUrl], + networkOrigins: disclosure.networkOrigins, commands: disclosure.commands, - integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`, + integrity: disclosure.integrity, }; }; diff --git a/src/application/SetupTypes.ts b/src/application/SetupTypes.ts index 2bdb3f5b4c..afebad211a 100644 --- a/src/application/SetupTypes.ts +++ b/src/application/SetupTypes.ts @@ -1,5 +1,6 @@ import type { DoctorReport, DoctorScope, runDoctor } from "./Doctor.js"; import type { LinuxDistribution } from "./LinuxHopper.js"; +import type { HopperReleaseError } from "./HopperReleaseFeed.js"; import type { SkillDestination, SkillInstallResult, @@ -67,6 +68,7 @@ export type ClientConfigurationInspection = export interface SetupHost { readonly platform: NodeJS.Platform; readonly homeDirectory: string; + readonly linuxHopperReleaseCache?: Result; skillDestinations(clientIds: readonly string[]): readonly SkillDestination[]; readonly registrationCommand: readonly string[]; /** Why client registration cannot proceed from this host. */ diff --git a/src/artifacts/ArtifactResourceScope.ts b/src/artifacts/ArtifactResourceScope.ts index 8728996311..f069845357 100644 --- a/src/artifacts/ArtifactResourceScope.ts +++ b/src/artifacts/ArtifactResourceScope.ts @@ -8,8 +8,13 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js"; import type { SafeOutputTree } from "./SafeOutputTree.js"; import type { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; -/** One artifact resource and its format-specific cleanup owner. */ +/** One artifact resource and the capability that owns its cleanup. */ export type ArtifactResourceOwner = + | { + readonly kind: "lease"; + readonly resource: string; + readonly lease: { release(): Promise }; + } | { readonly kind: "javascript-source-analysis"; readonly resource: string; @@ -156,6 +161,9 @@ export class ArtifactResourceScope { const cleanup = async (owner: ArtifactResourceOwner): Promise => { switch (owner.kind) { + case "lease": + await owner.lease.release(); + return; case "javascript-source-analysis": await owner.analysis.close(); return; diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index e73de5e92a..d916db49df 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -422,6 +422,9 @@ const providerErrorDetails = ( return { stage: "launch", provider_state: "unknown", + ...(error.launchRejection === undefined + ? {} + : { launch_rejection: { ...error.launchRejection } }), retry_action: error.ownerRunId === undefined ? "retry" : "unknown", ...(error.ownerRunId === undefined ? {} diff --git a/src/domain/hopperErrors.ts b/src/domain/hopperErrors.ts index 2ec3135c70..955991e653 100644 --- a/src/domain/hopperErrors.ts +++ b/src/domain/hopperErrors.ts @@ -148,23 +148,34 @@ export class HopperProcessError extends HopperError { } } +/** Parsed facts about rejecting an application before execution. */ +export interface HopperLaunchRejection { + /** The selected application was rejected before it was executed. */ + readonly boundary: "publisher_verification"; + /** Parsed failure reason supplied by the verification owner. */ + readonly reason: string; +} + /** Hopper or the repository bridge could not be started. */ export class HopperStartError extends HopperError { readonly _tag = "HopperStartError"; override readonly userMessage: string | undefined; readonly ownerRunId: string | undefined; readonly launcherFailure: HopperLauncherOutcome | undefined; + readonly launchRejection: HopperLaunchRejection | undefined; constructor( options?: ErrorOptions & { readonly userMessage?: string; readonly ownerRunId?: string; readonly launcherFailure?: HopperLauncherOutcome; + readonly launchRejection?: HopperLaunchRejection; }, ) { super("Hopper application bridge could not be started", options); this.userMessage = options?.userMessage; this.ownerRunId = options?.ownerRunId; this.launcherFailure = options?.launcherFailure; + this.launchRejection = options?.launchRejection; } } diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index c15ff8a101..0a98bcb759 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -69,7 +69,6 @@ const doctorHost = ( validTarget: () => Promise.resolve(true), executable: (path) => Promise.resolve(hopperAvailable && path.includes("Hopper")), - supportedLinuxHopper: () => Promise.resolve(true), linuxDemoRuntimeCheck: () => Promise.resolve({ name: "hopper-demo-runtime", diff --git a/src/hopper/BridgeLauncher.ts b/src/hopper/BridgeLauncher.ts index 40cba4288b..1cef1cf7d2 100644 --- a/src/hopper/BridgeLauncher.ts +++ b/src/hopper/BridgeLauncher.ts @@ -9,6 +9,8 @@ import { HopperStartError, } from "../domain/hopperErrors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import { @@ -30,10 +32,12 @@ import { import { acquireHopperTargetLease, acquireLinuxHopperApplicationLease, -} from "./HopperTargetLease.js"; -import type { HopperTargetLease } from "./HopperTargetLease.js"; +} from "../process/HopperLease.js"; +import type { HopperTargetLease } from "../process/HopperLease.js"; import { HOPPER_SHUTDOWN_TIMEOUT_MS } from "./HopperSessionValues.js"; import { verifyMacHopperNativeHelper } from "./HopperMacPublisher.js"; +import { inspectLinuxHopperLauncher } from "../application/LinuxHopper.js"; +import { retryLinuxHopperPublisherCleanup } from "../application/LinuxHopperPublisher.js"; const execFileAsync = promisify(execFile); const HOPPER_BACKGROUND_STARTUP_MS = 5_000; @@ -73,6 +77,8 @@ export type BridgeLaunch = { /** Application-owned capability that starts the in-Hopper bridge. */ export interface BridgeLauncher { + /** Retry owned pre-launch resources even when no application was started. */ + cleanup?(): Promise>; launch( session: BridgeSession, options?: { readonly signal?: AbortSignal }, @@ -110,6 +116,8 @@ export type HopperApplicationLauncherOptions = export interface HopperApplicationLauncherDependencies { readonly verifyNativeHelper?: typeof verifyMacHopperNativeHelper; + readonly verifyLinuxLauncher?: typeof inspectLinuxHopperLauncher; + readonly retryPublisherCleanup?: typeof retryLinuxHopperPublisherCleanup; readonly platform?: NodeJS.Platform; readonly acquireTargetLease?: typeof acquireHopperTargetLease; readonly acquireApplicationLease?: typeof acquireLinuxHopperApplicationLease; @@ -140,6 +148,32 @@ export class HopperApplicationLauncher implements BridgeLauncher { readonly dependencies: HopperApplicationLauncherDependencies = {}, ) {} + async cleanup(): Promise> { + if (!usesLinuxDemo(this.options)) return ok(null); + try { + await ( + this.dependencies.retryPublisherCleanup ?? + retryLinuxHopperPublisherCleanup + )(); + return ok(null); + } catch (cause: unknown) { + const observation = ArtifactReaderFailure.cleanupObservation( + cause, + "hopper-publisher-verification", + ); + return err( + new ProviderCleanupError( + "hopper", + observation.resources, + { + reason: observation.reason, + }, + { cause }, + ), + ); + } + } + async launch( session: BridgeSession, options: { readonly signal?: AbortSignal } = {}, @@ -218,6 +252,26 @@ export class HopperApplicationLauncher implements BridgeLauncher { if (signal?.aborted === true) return err(new HopperCancelledError()); const display = await this.#privateDisplay(signal); if (!display.ok) return err(display.error); + let expectedLinuxSha256: string | undefined; + if (usesLinuxDemo(this.options)) { + const inspected = await ( + this.dependencies.verifyLinuxLauncher ?? inspectLinuxHopperLauncher + )(this.options.launcherPath, this.options.environment, signal); + if (!inspected.ok) + return inspected.error.reason === "cancelled" + ? err(new HopperCancelledError()) + : err( + new HopperStartError({ + userMessage: inspected.error.message, + cause: inspected.error, + launchRejection: { + boundary: "publisher_verification", + reason: inspected.error.reason, + }, + }), + ); + expectedLinuxSha256 = inspected.value.sha256; + } const nativeHelperVerified = (this.dependencies.platform ?? process.platform) === "darwin" && this.options.launchMode === "native" && @@ -236,6 +290,7 @@ export class HopperApplicationLauncher implements BridgeLauncher { session, argumentsForTarget, display.strategy, + expectedLinuxSha256, ); const started = await launchHopperProcess({ options: this.options, @@ -449,6 +504,7 @@ export const linuxDemoLaunch = ( session: BridgeSession, hopperArgs: readonly string[], strategy: LinuxPrivateDisplayRunnableStrategy | undefined, + expectedSha256?: string, ): | { readonly command: string; @@ -458,6 +514,10 @@ export const linuxDemoLaunch = ( | undefined => { if (!usesLinuxDemo(options) || options.demoHelperPath === undefined) return undefined; + if (expectedSha256 === undefined || !/^[a-f0-9]{64}$/u.test(expectedSha256)) + throw new TypeError( + "Linux Hopper launch requires an authenticated launcher SHA-256", + ); const helperArguments = [ options.demoHelperPath, "--owner-stdin", @@ -466,6 +526,8 @@ export const linuxDemoLaunch = ( ...(strategy === "user-mount-namespace" ? ["--mount-private-x11"] : []), "--hopper", options.launcherPath, + "--expected-launcher-sha256", + expectedSha256, "--socket", session.socketPath, "--", @@ -501,7 +563,7 @@ const hopperLaunchFailure = ( ? new HopperCancelledError() : new HopperStartError({ cause }); -/** Select the version-pinned adapter from the caller's explicit launch mode. */ +/** Select the publisher-verified demo adapter from the caller's explicit launch mode. */ export const usesLinuxDemo = ( options: HopperApplicationLauncherOptions, ): options is HopperApplicationLauncherOptions & { diff --git a/src/hopper/HopperClient.ts b/src/hopper/HopperClient.ts index c81a2f2fa7..a0668c3a77 100644 --- a/src/hopper/HopperClient.ts +++ b/src/hopper/HopperClient.ts @@ -29,6 +29,7 @@ import { type ProviderOperationRequest, } from "../domain/providerOperationHealth.js"; import { err, ok, type Result } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { ProgressReporter } from "../application/ProgressReporter.js"; import { silentLogger } from "../logger.js"; @@ -328,7 +329,8 @@ export class HopperClient { if ( this.#socket !== undefined || this.#resources.launch !== undefined || - this.#resources.runtimeRoot !== undefined + this.#resources.runtimeRoot !== undefined || + this.#startupFailureResult?.cleanupIncomplete === true ) { return err( this.#startupFailureResult ?? @@ -563,7 +565,9 @@ export class HopperClient { this.#releaseTransport(), ); await this.#reconnectForCleanup(); - return await this.#cleanup(options); + const closed = await this.#cleanup(options); + if (closed.ok) this.#startupFailureResult = undefined; + return closed; } finally { if (this.#startPromise === starting) this.#startPromise = undefined; if (this.#startupController === controller) { @@ -641,7 +645,7 @@ export class HopperClient { this.#closing = true; this.#ready = false; try { - return await cleanupHopperSession({ + const closed = await cleanupHopperSession({ socket: this.#socket, resources: this.#resources, activeRequest: this.#requests.activity(), @@ -653,6 +657,26 @@ export class HopperClient { retainDocument: options.retainDocument === true, releaseTransport: () => this.#releaseTransport(), }); + const launcherClosed = await this.#options.launcher + .cleanup?.() + .catch((cause: unknown) => + err( + new ProviderCleanupError( + "hopper", + ["hopper-launcher"], + { + reason: cause instanceof Error ? cause.message : String(cause), + }, + { cause }, + ), + ), + ); + if (launcherClosed === undefined || launcherClosed.ok) return closed; + return closed.ok + ? launcherClosed + : err( + analysisErrorWithCleanupFailure(closed.error, launcherClosed.error), + ); } finally { if (this.#socket?.destroyed === true) this.#socket = undefined; diff --git a/src/hopper/HopperTargetLease.test.ts b/src/hopper/HopperTargetLease.test.ts index a0c8e02965..87d8355029 100644 --- a/src/hopper/HopperTargetLease.test.ts +++ b/src/hopper/HopperTargetLease.test.ts @@ -20,7 +20,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { acquireHopperTargetLease, acquireLinuxHopperApplicationLease, -} from "./HopperTargetLease.js"; +} from "../process/HopperLease.js"; const directories: string[] = []; diff --git a/src/process/DetachedPgpSignature.test.ts b/src/process/DetachedPgpSignature.test.ts new file mode 100644 index 0000000000..ee240089fa --- /dev/null +++ b/src/process/DetachedPgpSignature.test.ts @@ -0,0 +1,128 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createMessage, generateKey, readPrivateKey, sign } from "openpgp"; + +import { verifyDetachedPgpSignature } from "./DetachedPgpSignature.js"; + +const bytes = Buffer.from("binary package fixture\0\xff", "latin1"); +let publicKey: string; +let fingerprint: string; +let signatures: Uint8Array[]; +let signingFingerprints: string[]; + +beforeAll(async () => { + const generated = await generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Fixture publisher" }], + subkeys: [{ sign: true }, { sign: true }], + format: "armored", + }); + const privateKey = await readPrivateKey({ armoredKey: generated.privateKey }); + publicKey = generated.publicKey; + fingerprint = privateKey.getFingerprint().toUpperCase(); + signingFingerprints = privateKey.subkeys.map((subkey) => + subkey.getFingerprint().toUpperCase(), + ); + signatures = []; + for (const subkey of privateKey.subkeys) + signatures.push( + await sign({ + message: await createMessage({ binary: bytes }), + signingKeys: privateKey, + signingKeyIDs: [subkey.getKeyID()], + detached: true, + format: "binary", + }), + ); +}); + +describe("detached binary PGP publisher verification", () => { + it("accepts independently selected signing subkeys through one pinned primary", async () => { + for (const [index, signature] of signatures.entries()) + expect( + await verifyDetachedPgpSignature( + bytes, + signature, + publicKey, + fingerprint, + ), + ).toEqual({ + ok: true, + value: { + primaryFingerprint: fingerprint, + signingFingerprint: signingFingerprints[index], + }, + }); + }); + + it("rejects a valid signature belonging to another primary", async () => { + expect( + await verifyDetachedPgpSignature( + bytes, + signatures[0]!, + publicKey, + "0".repeat(40), + ), + ).toMatchObject({ ok: false, error: { reason: "wrong_publisher" } }); + }); + + it("rejects tampered payload bytes", async () => { + const tampered = Buffer.from(bytes); + tampered[0] = (tampered[0] ?? 0) ^ 1; + expect( + await verifyDetachedPgpSignature( + tampered, + signatures[0]!, + publicKey, + fingerprint, + ), + ).toMatchObject({ ok: false, error: { reason: "invalid_signature" } }); + }); + + it("rejects malformed signatures and public keys", async () => { + expect( + await verifyDetachedPgpSignature( + bytes, + new Uint8Array([1, 2, 3]), + publicKey, + fingerprint, + ), + ).toMatchObject({ ok: false, error: { reason: "invalid_signature" } }); + expect( + await verifyDetachedPgpSignature( + bytes, + signatures[0]!, + "malformed", + fingerprint, + ), + ).toMatchObject({ ok: false, error: { reason: "invalid_signature" } }); + }); + + it("preserves cancellation", async () => { + expect( + await verifyDetachedPgpSignature( + bytes, + signatures[0]!, + publicKey, + fingerprint, + AbortSignal.abort(), + ), + ).toMatchObject({ ok: false, error: { reason: "cancelled" } }); + }); + + it("preserves cancellation while loading the publisher key, before classifying its identity", async () => { + const controller = new AbortController(); + const verification = verifyDetachedPgpSignature( + bytes, + signatures[0]!, + publicKey, + "0".repeat(40), + controller.signal, + ); + queueMicrotask(() => controller.abort()); + expect(await verification).toMatchObject({ + ok: false, + error: { reason: "cancelled" }, + }); + }); +}); diff --git a/src/process/DetachedPgpSignature.ts b/src/process/DetachedPgpSignature.ts new file mode 100644 index 0000000000..8d1a215cd1 --- /dev/null +++ b/src/process/DetachedPgpSignature.ts @@ -0,0 +1,101 @@ +import { err, ok, type Result } from "../domain/result.js"; + +export class DetachedPgpSignatureError extends Error { + readonly _tag = "DetachedPgpSignatureError"; + constructor( + readonly reason: "invalid_signature" | "wrong_publisher" | "cancelled", + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = this._tag; + } +} + +/** Verify the complete binary payload and its signing subkey's primary identity. */ +export const verifyDetachedPgpSignature = async ( + bytes: Uint8Array, + detachedSignature: Uint8Array, + armoredPublicKey: string, + primaryFingerprint: string, + signal?: AbortSignal, +): Promise< + Result< + { + readonly primaryFingerprint: string; + readonly signingFingerprint: string; + }, + DetachedPgpSignatureError + > +> => { + const cancelled = () => + err( + new DetachedPgpSignatureError( + "cancelled", + "Package verification was cancelled.", + ), + ); + const isAborted = () => signal?.aborted === true; + if (isAborted()) return cancelled(); + try { + // Keep the cryptographic implementation out of unrelated CLI cold starts. + const pgp = await import("openpgp"); + const key = await pgp.readKey({ armoredKey: armoredPublicKey }); + if (isAborted()) return cancelled(); + const fingerprint = key.getFingerprint().toUpperCase(); + if (fingerprint !== primaryFingerprint) + return err( + new DetachedPgpSignatureError( + "wrong_publisher", + "The package signing key does not belong to the pinned publisher.", + ), + ); + const signature = await pgp.readSignature({ + binarySignature: detachedSignature, + }); + if (isAborted()) return cancelled(); + if ( + signature.packets.length !== 1 || + signature.packets[0]?.signatureType !== pgp.enums.signature.binary + ) + return err( + new DetachedPgpSignatureError( + "invalid_signature", + "Expected one detached binary package signature.", + ), + ); + const verified = await pgp.verify({ + message: await pgp.createMessage({ binary: bytes }), + signature, + verificationKeys: key, + format: "binary", + }); + const signed = verified.signatures[0]; + if (signed === undefined || verified.signatures.length !== 1) + return err( + new DetachedPgpSignatureError( + "invalid_signature", + "The package has no unique verified signature.", + ), + ); + await signed.verified; + const signingKey = await key.getSigningKey( + signed.keyID, + signature.packets[0].created, + ); + if (isAborted()) return cancelled(); + return ok({ + primaryFingerprint: fingerprint, + signingFingerprint: signingKey.getFingerprint().toUpperCase(), + }); + } catch (cause: unknown) { + if (isAborted()) return cancelled(); + return err( + new DetachedPgpSignatureError( + "invalid_signature", + "The package signature could not be verified.", + { cause }, + ), + ); + } +}; diff --git a/src/hopper/HopperTargetLease.ts b/src/process/HopperLease.ts similarity index 94% rename from src/hopper/HopperTargetLease.ts rename to src/process/HopperLease.ts index 53d09aba14..51f1b66407 100644 --- a/src/hopper/HopperTargetLease.ts +++ b/src/process/HopperLease.ts @@ -17,6 +17,8 @@ interface HopperTargetLeaseOwner { } export interface HopperTargetLease { + /** Bound socket identity for cleanup diagnostics; omission remains unknown. */ + readonly socketPath?: string; readonly release: () => Promise; } @@ -66,6 +68,21 @@ export const acquireLinuxHopperApplicationLease = (input: { }): Promise => acquireLease({ ...input, identity: { application: "linux-hopper-demo" } }); +/** Serialize approved publication/repair of one canonical signed-package entry. */ +export const acquireHopperReleaseCacheLease = async (input: { + readonly cacheDirectory: string; + readonly launcherSha256: string; + readonly runId: string; +}): Promise => + acquireLease({ + runId: input.runId, + identity: { + cacheDirectory: await realpath(input.cacheDirectory), + launcherSha256: input.launcherSha256, + operation: "signed-package-retention", + }, + }); + const acquireLease = async (input: { readonly identity: Readonly>; readonly runId: string; @@ -153,6 +170,7 @@ const tryAcquireLease = async ( return { acquired: true, lease: { + socketPath, release: async () => { if (released) return; // Node removes a bound Unix socket on close. A second unlink here diff --git a/tests/acceptance/providers/hopperStartupRecovery.test.ts b/tests/acceptance/providers/hopperStartupRecovery.test.ts index bd4a2d0f1e..2cf22c9b90 100644 --- a/tests/acceptance/providers/hopperStartupRecovery.test.ts +++ b/tests/acceptance/providers/hopperStartupRecovery.test.ts @@ -10,7 +10,7 @@ import { z } from "zod"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; const statusSchema = z.object({ result: z.object({ open: z.boolean() }) }); const test = workspaceCliTest.skipIf(process.platform !== "darwin"); diff --git a/tests/boundary/cli/doctorHopperDiscovery.test.ts b/tests/boundary/cli/doctorHopperDiscovery.test.ts index 12df465d77..db4e442600 100644 --- a/tests/boundary/cli/doctorHopperDiscovery.test.ts +++ b/tests/boundary/cli/doctorHopperDiscovery.test.ts @@ -45,6 +45,7 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( ? { PATH: [join(home, "bin"), process.env.PATH ?? ""].join(delimiter), REA_HOPPER_LDD_TEST_MARKER: loaderMarker, + NODE_OPTIONS: `--import=${new URL("../../fixtures/hopperRejectionFeed.mjs", import.meta.url).href}`, } : {}), HOME: home, diff --git a/tests/boundary/cli/hopperLaunchFailureCli.test.ts b/tests/boundary/cli/hopperLaunchFailureCli.test.ts index 9a4f55b7cb..29e132b8a0 100644 --- a/tests/boundary/cli/hopperLaunchFailureCli.test.ts +++ b/tests/boundary/cli/hopperLaunchFailureCli.test.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; const FAILED_LAUNCHER = `#!/bin/sh printf 'Cannot launch Hopper!\\nDocument state is unknown.\\n' >&2 diff --git a/tests/boundary/process/binarySessionCleanupRetry.test.ts b/tests/boundary/process/binarySessionCleanupRetry.test.ts index 5d58f94203..38b3b1ef7e 100644 --- a/tests/boundary/process/binarySessionCleanupRetry.test.ts +++ b/tests/boundary/process/binarySessionCleanupRetry.test.ts @@ -9,7 +9,7 @@ import type { BridgeSession, } from "../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../src/hopper/HopperClient.js"; -import { acquireHopperTargetLease } from "../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../src/process/HopperLease.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { hopperFixturePath } from "../providers/hopper/hopperClient.fixture.js"; diff --git a/tests/boundary/providers/hopper/bridgeLauncher.test.ts b/tests/boundary/providers/hopper/bridgeLauncher.test.ts index 542858a0df..155a6414f0 100644 --- a/tests/boundary/providers/hopper/bridgeLauncher.test.ts +++ b/tests/boundary/providers/hopper/bridgeLauncher.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -17,8 +17,38 @@ import { } from "../../../../src/hopper/BridgeLauncher.js"; import { ProviderProcessSupervisor } from "../../../../src/process/ProviderProcess.js"; +import { err, ok } from "../../../../src/domain/result.js"; + +import { HopperReleaseError } from "../../../../src/application/HopperReleaseFeed.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; +import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; +import { HopperClient } from "../../../../src/hopper/HopperClient.js"; + +const readyDirectDisplay = async () => + ({ + ok: true, + strategy: "direct", + diagnostic: { + component: "hopper_private_display", + operation: "probe", + status: "ready", + failure_code: null, + reason: "ready", + socket_directory: "/tmp/.X11-unix", + socket_directory_mode: "1777", + mount_read_only: false, + effective_socket_directory_mode: "1777", + effective_mount_read_only: false, + wsl: false, + strategy: "direct", + fallback_reason: null, + xvfb_stderr_bytes: 0, + }, + }) as const; const execFileAsync = promisify(execFile); +const verifiedSha256 = "0".repeat(64); const demoHelperPath = fileURLToPath( new URL("../../../../scripts/hopper-demo-x11.py", import.meta.url), ); @@ -34,13 +64,93 @@ const options = (launcherPath: string): HopperApplicationLauncherOptions => ({ demoHelperPath: "/rea/hopper-demo-x11.py", }); +it("retains cancelled verification cleanup through client shutdown and retries it", async () => { + const directory = await createTestTempDirectory("rea-publisher-cancel-"); + const targetPath = join(directory, "target"); + await writeFile(targetPath, "source-owned target"); + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-publisher-pending-", + }); + const resources = new ArtifactResourceScope(); + let allowCleanup = false; + expect( + await resources.release({ + kind: "private-runtime-root", + resource: root.path, + root: { + path: root.path, + close: async () => { + if (!allowCleanup) + throw new Error("Fixture publisher cleanup denied"); + await root.close(); + }, + }, + }), + ).toMatchObject({ kind: "failed" }); + const controller = new AbortController(); + const verify = vi.fn(async () => { + controller.abort(); + return err( + new HopperReleaseError( + "cleanup_failed", + "Publisher cleanup remains owned", + ), + ); + }); + const launcher = new HopperApplicationLauncher( + { + ...options(process.execPath), + targetPath, + }, + { + verifyLinuxLauncher: verify, + retryPublisherCleanup: () => resources.close(), + selectPrivateDisplay: readyDirectDisplay, + }, + ); + const client = new HopperClient({ launcher, startupTimeoutMs: 5_000 }); + try { + const started = await client.start(controller.signal); + expect(started).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true, cleanupResources: [root.path] }, + }); + if (started.ok) + throw new Error("Expected cancelled startup with pending cleanup"); + expect(projectAnalysisError(started.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { primary_error: { code: "cancelled" } }, + resources: [root.path], + }, + }); + expect(await client.start()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(verify).toHaveBeenCalledOnce(); + expect(await client.close()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + await access(root.path); + allowCleanup = true; + expect(await client.close()).toEqual({ ok: true, value: null }); + await expect(access(root.path)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + allowCleanup = true; + await client.close(); + await resources.close(); + } +}); + describe("Hopper bridge launcher selection", () => { it.each([ "/opt/hopper/bin/Hopper", "/usr/local/bin/hopper", "/workspace/bin/hopper-wrapper", ])( - "routes launcher spelling %s through the explicitly selected pinned adapter", + "routes launcher spelling %s through the explicitly selected demo adapter", (path) => { expect(usesLinuxDemo(options(path))).toBe(true); }, @@ -71,6 +181,7 @@ describe("Hopper bridge launcher selection", () => { }, ["--analysis"], "user-mount-namespace", + verifiedSha256, ); expect(launch).toMatchObject({ @@ -90,6 +201,8 @@ describe("Hopper bridge launcher selection", () => { "--mount-private-x11", "--hopper", "/opt/hopper/bin/Hopper", + "--expected-launcher-sha256", + verifiedSha256, "--socket", "/tmp/rea-fixture/bridge.sock", "--", @@ -113,6 +226,7 @@ describe("Hopper bridge launcher selection", () => { }, [], "direct", + verifiedSha256, ), ).toMatchObject({ command: "/usr/bin/python3", @@ -123,6 +237,8 @@ describe("Hopper bridge launcher selection", () => { "direct", "--hopper", "/opt/hopper/bin/Hopper", + "--expected-launcher-sha256", + verifiedSha256, "--socket", "/tmp/rea-fixture/bridge.sock", "--", @@ -131,7 +247,7 @@ describe("Hopper bridge launcher selection", () => { }); }); - it("rejects an unpinned wrapper before executing it", async () => { + it("rejects launcher bytes changed since publisher verification before executing them", async () => { const directory = await createTestTempDirectory("rea-hopper-wrapper-"); const wrapper = join(directory, "hopper-wrapper"); const marker = join(directory, "executed"); @@ -145,6 +261,8 @@ describe("Hopper bridge launcher selection", () => { demoHelperPath, "--hopper", wrapper, + "--expected-launcher-sha256", + verifiedSha256, "--socket", join(directory, "bridge.sock"), "--", @@ -218,26 +336,15 @@ it.skipIf(process.platform !== "linux")( ), }, { - selectPrivateDisplay: async () => ({ - ok: true, - strategy: "direct", - diagnostic: { - component: "hopper_private_display", - operation: "probe", - status: "ready", - failure_code: null, - reason: "ready", - socket_directory: "/tmp/.X11-unix", - socket_directory_mode: "1777", - mount_read_only: false, - effective_socket_directory_mode: "1777", - effective_mount_read_only: false, - wsl: false, - strategy: "direct", - fallback_reason: null, - xvfb_stderr_bytes: 0, - }, - }), + verifyLinuxLauncher: async () => + ok({ + sha256: verifiedSha256, + version: "fixture", + primaryFingerprint: "0".repeat(40), + signingFingerprint: "1".repeat(40), + source: "signed_package_cache", + }), + selectPrivateDisplay: readyDirectDisplay, }, ); const launched = await launcher.launch({ @@ -260,3 +367,43 @@ it.skipIf(process.platform !== "linux")( } }, ); + +it.skipIf(process.platform !== "linux")( + "preserves a publisher rejection through the public error without inventing a helper exit", + async () => { + const directory = await createTestTempDirectory("rea-publisher-rejection-"); + const targetPath = join(directory, "target"); + await writeFile(targetPath, "fixture target"); + const rejected = new HopperReleaseError( + "unsupported_hopper_build", + "Fixture launcher is outside verified releases", + ); + const launcher = new HopperApplicationLauncher( + { ...options("/unused/Hopper"), targetPath }, + { + selectPrivateDisplay: readyDirectDisplay, + verifyLinuxLauncher: async () => err(rejected), + }, + ); + const result = await launcher.launch({ + directory, + socketPath: join(directory, "bridge.sock"), + token: "fixture-token", + runId: randomUUID(), + }); + if (result.ok) throw new Error("Unverified launcher started"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: rejected.reason, + }, + }, + }); + expect(projected.details).not.toHaveProperty("exit_code"); + expect(projected.details).not.toHaveProperty("launcher"); + }, +); diff --git a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts index b236b21ebe..945a5346d8 100644 --- a/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.helperFailure.test.ts @@ -15,7 +15,7 @@ import type { } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperApplicationLauncher } from "../../../../src/hopper/BridgeLauncher.js"; import { HopperClient } from "../../../../src/hopper/HopperClient.js"; -import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js"; import { HopperFixtureLauncher } from "./hopperClient.fixture.js"; // Failure assertions keep the external document unknown. Teardown then publishes diff --git a/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts b/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts index 488ac40db5..2678c57026 100644 --- a/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts +++ b/tests/boundary/providers/hopper/hopperVerificationCleanup.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { snapshotHopperRuntime } from "../../../../scripts/lib/real-hopper-cleanup.mjs"; -import { acquireHopperTargetLease } from "../../../../src/hopper/HopperTargetLease.js"; +import { acquireHopperTargetLease } from "../../../../src/process/HopperLease.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; it.skipIf(process.platform === "win32")( diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts new file mode 100644 index 0000000000..527a5946a0 --- /dev/null +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -0,0 +1,765 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { promises as filesystem } from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { + access, + copyFile, + cp, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +import { createMessage, generateKey, readPrivateKey, sign } from "openpgp"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + LinuxHopperPublisher, + linuxHopperReleaseCacheDirectory, + retryLinuxHopperPublisherCleanup, +} from "../../../src/application/LinuxHopperPublisher.js"; +import { + HOPPER_LINUX_PRIMARY_FINGERPRINT, + type LinuxHopperRelease, +} from "../../../src/application/HopperReleaseFeed.js"; +import * as signatures from "../../../src/process/DetachedPgpSignature.js"; +import { downloadHopperPackage } from "../../../src/application/HopperDownload.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import * as leases from "../../../src/process/HopperLease.js"; +import * as nativeCommands from "../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createTestProcessRegistry } from "../../support/process/processFixture.js"; + +const run = promisify(execFile); +const verifySignature = signatures.verifyDetachedPgpSignature; +const sha256 = (bytes: Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); +const linux = it.skipIf(process.platform !== "linux"); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +/** Real native archives and cryptography; substitute only the test publisher identity. */ +const fixture = async (family: "deb" | "arch" = "deb") => { + const directory = await createTestTempDirectory("rea-linux-publisher-"); + const packageRoot = join(directory, "package-root"); + const launcher = join(packageRoot, "opt/hopper/bin/Hopper"); + await mkdir(join(packageRoot, "opt/hopper/bin"), { recursive: true }); + await copyFile("/usr/bin/true", launcher); + const archive = join(directory, "archive"); + if (family === "deb") { + await mkdir(join(packageRoot, "DEBIAN")); + await writeFile( + join(packageRoot, "DEBIAN/control"), + "Package: hopper-fixture\nVersion: 1.0\nArchitecture: amd64\nMaintainer: Fixture\nDescription: Signature boundary fixture\n", + ); + await run("dpkg-deb", ["--build", packageRoot, archive]); + } else await run("tar", ["-cJf", archive, "-C", packageRoot, "opt"]); + const bytes = await readFile(archive); + const generated = await generateKey({ + type: "ecc", + curve: "curve25519Legacy", + userIDs: [{ name: "Fixture publisher" }], + format: "armored", + }); + const key = await readPrivateKey({ armoredKey: generated.privateKey }); + const signature = await sign({ + message: await createMessage({ binary: bytes }), + signingKeys: key, + detached: true, + format: "binary", + }); + const release: LinuxHopperRelease = { + version: "1.0", + filename: `https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.${family === "deb" ? "deb" : "pkg.tar.xz"}`, + file_length: String(bytes.length), + file_hash: createHash("sha1").update(bytes).digest("hex"), + main_exe_hash: sha256(await readFile(launcher)), + signature: Buffer.from(signature).toString("base64"), + public: "1", + beta: "0", + }; + const publisher = new LinuxHopperPublisher( + { ...process.env, XDG_CACHE_HOME: join(directory, "cache") }, + async () => family, + ); + const cache = join(publisher.cacheRoot, release.main_exe_hash); + return { + publisher, + archive, + launcher, + release, + cache, + publicKey: generated.publicKey, + fingerprint: key.getFingerprint().toUpperCase(), + }; +}; + +const admitFixturePublisher = ( + fingerprint: string, + module: typeof signatures = signatures, +) => + vi + .spyOn(module, "verifyDetachedPgpSignature") + .mockImplementation((bytes, signature, key, primary, signal) => { + expect(primary).toBe(HOPPER_LINUX_PRIMARY_FINGERPRINT); + return verifySignature(bytes, signature, key, fingerprint, signal); + }); + +it.each([undefined, "", "/selected/cache", "relative/cache"])( + "resolves the selected XDG cache setting %j once for every consumer", + (setting) => { + const selected = linuxHopperReleaseCacheDirectory({ + HOME: "/caller/home", + ...(setting === undefined ? {} : { XDG_CACHE_HOME: setting }), + }); + if (setting === "relative/cache") + expect(selected).toMatchObject({ ok: false }); + else + expect(selected).toEqual({ + ok: true, + value: join(setting || "/caller/home/.cache", "rea/hopper-releases"), + }); + }, +); + +describe("Linux signed package retention boundary", () => { + linux( + "rejects a cryptographically valid package signed by another publisher", + async () => { + const f = await fixture(); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { reason: "publisher_verification_failed" }, + }); + await expect(access(f.publisher.cacheRoot)).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + await f.publisher.close(); + } + }, + ); + + linux.each(["deb", "arch"] as const)( + "authenticates a real %s payload and verifies a retained install without network", + async (family) => { + const f = await fixture(family); + admitFixturePublisher(f.fingerprint); + const network = vi.fn(() => { + throw new Error("Offline"); + }); + vi.stubGlobal("fetch", network); + try { + const verified = await f.publisher.verifyArchive( + family, + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + await f.publisher.close(); + // A new instance alone still shares process-local cryptographic proofs. + // Reload the owner and assert actual PGP and native payload inspection. + vi.resetModules(); + const coldSignatures = + await import("../../../src/process/DetachedPgpSignature.js"); + const signature = admitFixturePublisher(f.fingerprint, coldSignatures); + const coldCommands = + await import("../../../src/process/ExecFileOutput.js"); + const extraction = vi.spyOn(coldCommands, "execFileOutput"); + const { LinuxHopperPublisher: ColdPublisher } = + await import("../../../src/application/LinuxHopperPublisher.js"); + const fresh = new ColdPublisher( + f.publisher.environment, + async () => family, + ); + try { + expect(await fresh.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + value: { + sha256: f.release.main_exe_hash, + primaryFingerprint: f.fingerprint, + source: "signed_package_cache", + }, + }); + expect(network).not.toHaveBeenCalled(); + expect(signature).toHaveBeenCalledOnce(); + expect(extraction).toHaveBeenCalledWith( + family === "deb" ? "dpkg-deb" : "tar", + expect.any(Array), + expect.any(Object), + ); + } finally { + await fresh.close(); + } + } finally { + await f.publisher.close(); + } + }, + ); +}); + +linux.each([ + "archive", + "metadata", + "missing metadata", + "missing key", + "malformed key", + "missing archive", + "malformed JSON", + "empty directory", + "symlink archive", +])("fails closed on retained %s tampering", async (kind) => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + const retained = await f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + ); + if (!retained.ok) throw retained.error; + if (kind === "archive") { + const tampered = Buffer.from("tampered"); + await writeFile(join(f.cache, "archive"), tampered); + const metadata = JSON.parse( + await readFile(join(f.cache, "release.json"), "utf8"), + ); + metadata.release.file_length = String(tampered.length); + metadata.release.file_hash = createHash("sha1") + .update(tampered) + .digest("hex"); + await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata)); + } + if (kind === "metadata") { + const metadata = JSON.parse( + await readFile(join(f.cache, "release.json"), "utf8"), + ); + metadata.release.main_exe_hash = "a".repeat(64); + await writeFile(join(f.cache, "release.json"), JSON.stringify(metadata)); + } + if (kind === "missing metadata") await rm(join(f.cache, "release.json")); + if (kind === "missing key") await rm(join(f.cache, "public-key.asc")); + if (kind === "malformed key") + await writeFile(join(f.cache, "public-key.asc"), Buffer.from([0xff])); + if (kind === "missing archive") await rm(join(f.cache, "archive")); + if (kind === "malformed JSON") + await writeFile(join(f.cache, "release.json"), "{broken JSON"); + if (kind === "empty directory") + await Promise.all( + ["release.json", "public-key.asc", "archive"].map((name) => + rm(join(f.cache, name)), + ), + ); + if (kind === "symlink archive") { + await rm(join(f.cache, "archive")); + await symlink(f.archive, join(f.cache, "archive")); + } + const network = vi.fn(() => { + throw new Error("Must preserve the cache failure"); + }); + vi.stubGlobal("fetch", network); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: false, + }); + expect(network).not.toHaveBeenCalled(); + // An explicitly approved fresh package can repair this entry while + // preserving the original evidence and keeping ordinary reads strict. + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const backups = (await readdir(f.publisher.cacheRoot)).filter((name) => + name.startsWith(`${f.release.main_exe_hash}.backup-`), + ); + expect(backups).toHaveLength(1); + if (kind === "missing metadata" || kind === "empty directory") + await expect( + access(join(f.publisher.cacheRoot, backups[0]!, "release.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + else + expect( + await readFile( + join(f.publisher.cacheRoot, backups[0]!, "release.json"), + "utf8", + ), + ).toBeTruthy(); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + expect( + (await readdir(f.publisher.cacheRoot)).filter((name) => + name.includes(".backup-"), + ), + ).toEqual(backups); + expect(network).not.toHaveBeenCalled(); + } finally { + await f.publisher.close(); + } +}); + +linux.each([false, true])( + "serializes real cross-process repair and cancellation: %s", + async (cancel) => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const processes = createTestProcessRegistry(); + const controller = new AbortController(); + let retention: + | ReturnType + | undefined; + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const staged = join(f.publisher.cacheRoot, "authenticated-fixture-stage"); + await cp(f.cache, staged, { recursive: true }); + const broken = "{interrupted cache record"; + await writeFile(join(f.cache, "release.json"), broken); + const child = processes.spawn(process.execPath, [ + fileURLToPath( + new URL("../../fixtures/hopperCachePublisher.mjs", import.meta.url), + ), + f.publisher.cacheRoot, + f.release.main_exe_hash, + staged, + ]); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const exited = once(child, "close"); + const ready = await Promise.race([ + once(child.stdout, "data").then(([bytes]) => { + if (!Buffer.isBuffer(bytes)) + throw new Error("Expected fixture readiness bytes"); + return JSON.parse(bytes.toString()); + }), + exited.then(([code]) => { + throw new Error( + `Fixture exited before acquiring its lease: ${String(code)} ${stderr}`, + ); + }), + ]); + expect(ready).toEqual({ held: true }); + let notifyBusy: (() => void) | undefined; + const busy = new Promise((resolve) => { + notifyBusy = resolve; + }); + const acquire = leases.acquireHopperReleaseCacheLease; + vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation( + async (input) => { + const observed = await acquire(input); + if (!observed.acquired) notifyBusy?.(); + return observed; + }, + ); + retention = f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + controller.signal, + ); + await Promise.race([ + busy, + retention.then(() => { + throw new Error("Retention bypassed the other process's cache lease"); + }), + ]); + expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe( + broken, + ); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + if (cancel) { + controller.abort(); + expect(await retention).toMatchObject({ + ok: false, + error: { reason: "cancelled" }, + }); + child.stdin.end("release\n"); + } else { + child.stdin.end("publish\n"); + expect(await retention).toEqual({ ok: true, value: undefined }); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + expect( + (await readdir(f.publisher.cacheRoot)).filter((name) => + name.includes(".backup-"), + ), + ).toHaveLength(1); + } + expect(await exited).toEqual([0, null]); + } finally { + controller.abort(); + await processes.shutdown(); + await retention; + await f.publisher.close(); + } + }, +); + +linux("restores old evidence when replacement publication fails", async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + const broken = "{interrupted cache metadata"; + await writeFile(join(f.cache, "release.json"), broken); + const rename = filesystem.rename; + const denied = Object.assign(new Error("Fixture publication failure"), { + code: "EIO", + }); + let backup: string | undefined; + const publication = vi + .spyOn(filesystem, "rename") + .mockImplementation(async (from, to) => { + if (backup !== undefined && to === f.cache && from !== backup) + throw denied; + await rename(from, to); + if (from === f.cache && typeof to === "string") backup = to; + }); + syncBuiltinESMExports(); + try { + const result = await f.publisher.retainVerifiedArchive( + verified.value, + f.archive, + ); + expect(result).toMatchObject({ + ok: false, + error: { + reason: "verification_unavailable", + message: expect.stringContaining(f.cache), + }, + }); + if (result.ok) throw new Error("Expected publication failure"); + expect(result.error.cause).toBeInstanceOf(AggregateError); + if (result.error.cause instanceof AggregateError) + expect(result.error.cause.errors).toContain(denied); + expect(await readFile(join(f.cache, "release.json"), "utf8")).toBe( + broken, + ); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + } finally { + publication.mockRestore(); + syncBuiltinESMExports(); + } + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + } finally { + await f.publisher.close(); + } +}); + +linux( + "preserves existing evidence when native cache inspection is unavailable", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toEqual({ ok: true, value: undefined }); + await writeFile(join(f.cache, "public-key.asc"), f.publicKey + "\n"); + const metadata = await readFile(join(f.cache, "release.json")); + const unavailable = vi + .spyOn(nativeCommands, "execFileOutput") + .mockRejectedValue(new Error("Fixture native inspection unavailable")); + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toMatchObject({ + ok: false, + error: { reason: "verification_unavailable" }, + }); + expect(await readFile(join(f.cache, "release.json"))).toEqual(metadata); + expect( + (await readdir(f.publisher.cacheRoot)).some((name) => + name.includes(".backup-"), + ), + ).toBe(false); + unavailable.mockRestore(); + expect(await f.publisher.inspectLauncher(f.launcher)).toMatchObject({ + ok: true, + }); + } finally { + await f.publisher.close(); + } + }, +); + +linux( + "rejects a feed digest not represented in the signed payload", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + try { + expect( + await f.publisher.verifyArchive( + "deb", + { ...f.release, main_exe_hash: "a".repeat(64) }, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { + reason: "integrity", + message: expect.stringContaining(f.release.main_exe_hash), + }, + }); + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + AbortSignal.abort(), + f.publicKey, + ), + ).toMatchObject({ ok: false, error: { reason: "cancelled" } }); + } finally { + await f.publisher.close(); + } + }, +); + +linux( + "retains a failed publication lease until its real socket owner closes", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const acquire = leases.acquireHopperReleaseCacheLease; + let allowCleanup = false; + vi.spyOn(leases, "acquireHopperReleaseCacheLease").mockImplementation( + async (input) => { + const acquired = await acquire(input); + if (!acquired.acquired) return acquired; + return { + acquired: true as const, + lease: { + ...acquired.lease, + release: async () => { + if (!allowCleanup) + throw new Error("Fixture lease close unavailable"); + await acquired.lease.release(); + }, + }, + }; + }, + ); + try { + const verified = await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ); + if (!verified.ok) throw verified.error; + expect( + await f.publisher.retainVerifiedArchive(verified.value, f.archive), + ).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } }); + const input = { + cacheDirectory: f.publisher.cacheRoot, + launcherSha256: f.release.main_exe_hash, + runId: "fixture-competing-writer", + }; + expect(await acquire(input)).toMatchObject({ acquired: false }); + await expect(f.publisher.close()).rejects.toMatchObject({ + cleanup: { resources: [expect.stringContaining(".sock")] }, + }); + allowCleanup = true; + await f.publisher.close(); + const recovered = await acquire(input); + if (!recovered.acquired) + throw new Error("Expected the released real cache lease"); + await recovered.lease.release(); + } finally { + allowCleanup = true; + await f.publisher.close(); + } + }, +); + +it("bounds a stalled metadata fetch and preserves caller cancellation", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + (_url, options: RequestInit) => + new Promise((_resolve, reject) => { + const signal = options.signal; + signal?.addEventListener("abort", () => reject(signal.reason), { + once: true, + }); + signal?.throwIfAborted(); + }), + ), + ); + await expect( + downloadHopperPackage("https://www.hopperapp.com/fixture", { + timeoutMs: 10, + }), + ).rejects.toMatchObject({ name: "TimeoutError" }); + const controller = new AbortController(); + const reason = new Error("Caller cancelled metadata inspection"); + const pending = downloadHopperPackage("https://www.hopperapp.com/fixture", { + signal: controller.signal, + }); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); +}); +it("cancels an oversized chunked metadata response before retaining it", async () => { + const cancelled = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)); + }, + cancel: cancelled, + }), + ), + ), + ); + expect( + await downloadHopperPackage("https://www.hopperapp.com/fixture", { + maxBytes: 3, + }), + ).toEqual({ ok: false, bytes: new Uint8Array() }); + expect(cancelled).toHaveBeenCalledOnce(); +}); + +linux("retains and retries failed verification cleanup", async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const cleanup = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockRejectedValueOnce(new Error("Fixture cleanup unavailable")); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + undefined, + f.publicKey, + ), + ).toMatchObject({ ok: false, error: { reason: "cleanup_failed" } }); + expect(cleanup).toHaveBeenCalledOnce(); + await f.publisher.close(); + expect(cleanup).toHaveBeenCalledTimes(2); + } finally { + await f.publisher.close(); + } +}); + +linux( + "preserves cleanup ownership when verification is cancelled", + async () => { + const f = await fixture(); + admitFixturePublisher(f.fingerprint); + const controller = new AbortController(); + const cleanup = vi + .spyOn(PrivateRuntimeRoot.prototype, "close") + .mockImplementation(async () => { + controller.abort(); + throw new Error("Fixture cleanup unavailable after cancellation"); + }); + try { + expect( + await f.publisher.verifyArchive( + "deb", + f.release, + f.archive, + controller.signal, + f.publicKey, + ), + ).toMatchObject({ + ok: false, + error: { reason: "cleanup_failed" }, + }); + await expect(retryLinuxHopperPublisherCleanup()).rejects.toMatchObject({ + cleanup: { + resources: [expect.stringContaining("rea-hopper-publisher-")], + }, + }); + } finally { + cleanup.mockRestore(); + await f.publisher.close(); + await retryLinuxHopperPublisherCleanup(); + } + }, +); diff --git a/tests/evaluation/package/packagePlatformDiagnostics.test.ts b/tests/evaluation/package/packagePlatformDiagnostics.test.ts index d4f88b875b..1f4ea4644f 100644 --- a/tests/evaluation/package/packagePlatformDiagnostics.test.ts +++ b/tests/evaluation/package/packagePlatformDiagnostics.test.ts @@ -72,9 +72,54 @@ describe("packaged Linux failure verification", () => { }), ), ).not.toThrow(); + expect(() => + assertLinuxPackageProviderFailure( + execution({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: "unsupported_hopper_build", + }, + }, + }), + ), + ).not.toThrow(); }); it.each([ + execution({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: "download", + }, + }, + }), + execution({ + code: "provider_unavailable", + details: { + stage: "analysis", + launch_rejection: { + boundary: "publisher_verification", + reason: "unsupported_hopper_build", + }, + }, + }), + execution({ + code: "provider_unavailable", + details: { + stage: "launch", + launch_rejection: { + boundary: "publisher_verification", + reason: "unsupported_hopper_build", + }, + exit_code: 72, + }, + }), execution(missingDisplay, 0), execution({ code: "provider_unavailable" }), execution({ diff --git a/tests/fixtures/hopperCachePublisher.mjs b/tests/fixtures/hopperCachePublisher.mjs new file mode 100644 index 0000000000..3a49ed3f9c --- /dev/null +++ b/tests/fixtures/hopperCachePublisher.mjs @@ -0,0 +1,31 @@ +import { once } from "node:events"; +import { randomUUID } from "node:crypto"; +import { rename } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; + +import { acquireHopperReleaseCacheLease } from "../../dist/process/HopperLease.js"; + +// The parent prepares authenticated fixture evidence. Coordinate a real second +// process publishing it while another publisher is waiting for this lease. +const [cacheDirectory, launcherSha256, staged] = process.argv.slice(2); +const commands = createInterface({ input: process.stdin }); +const command = once(commands, "line"); +const acquired = await acquireHopperReleaseCacheLease({ + cacheDirectory, + launcherSha256, + runId: randomUUID(), +}); +if (!acquired.acquired) throw new Error("Fixture cache lease was already held"); +try { + console.log(JSON.stringify({ held: true })); + const [action] = await command; + if (action === "publish") { + const target = join(cacheDirectory, launcherSha256); + await rename(target, `${target}.backup-${randomUUID()}`); + await rename(staged, target); + } else if (action !== "release") throw new Error("Unknown fixture action"); +} finally { + await acquired.lease.release(); + commands.close(); +} diff --git a/tests/fixtures/hopperOwnerLifetime.py b/tests/fixtures/hopperOwnerLifetime.py index 741bffbd9d..8fcbf9893e 100644 --- a/tests/fixtures/hopperOwnerLifetime.py +++ b/tests/fixtures/hopperOwnerLifetime.py @@ -64,7 +64,7 @@ if len(sys.argv) > 3: ) return child, ":123", directory / "Xauthority", helper.CapturedStderr(child.stderr) - helper.digest = lambda _: next(iter(helper.SUPPORTED_HOPPER_SHA256)) + helper.digest = lambda _: "0" * 64 helper.start_xvfb = start_xvfb helper.ctypes.CDLL = lambda _: X11() helper.configure_x11 = lambda *_: None @@ -75,6 +75,7 @@ if len(sys.argv) > 3: raise SystemExit(helper.run_launch(argparse.Namespace( owner_stdin=True, hopper=sys.executable, + expected_launcher_sha256="0" * 64, socket=str(socket), command=child_command(directory / "hopper.pid"), ))) diff --git a/tests/fixtures/hopperRejectionFeed.mjs b/tests/fixtures/hopperRejectionFeed.mjs new file mode 100644 index 0000000000..04b2b187bb --- /dev/null +++ b/tests/fixtures/hopperRejectionFeed.mjs @@ -0,0 +1,36 @@ +/** Deterministic feed boundary for the package verifier's deliberately foreign launcher. + * This fixture never authorizes a Hopper build or simulates successful cryptography. + */ +import { HOPPER_RELEASES_URL } from "../../dist/application/HopperReleaseFeed.js"; + +const systemFetch = globalThis.fetch; +const row = (suffix) => ({ + version: "fixture-current", + filename: `https://www.hopperapp.com:443/downloader/public/Hopper-fixture-current-Linux-demo.${suffix}`, + file_length: "1", + file_hash: "0".repeat(40), + main_exe_hash: "0".repeat(64), + signature: "AQ==", + public: "1", + beta: "0", +}); + +globalThis.fetch = (input, options) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (url !== HOPPER_RELEASES_URL) return systemFetch(input, options); + options?.signal?.throwIfAborted(); + return Promise.resolve( + new Response( + JSON.stringify({ + "Ubuntu / Mint": row("deb"), + Fedora: row("rpm"), + Arch: row("pkg.tar.xz"), + }), + ), + ); +}; diff --git a/tests/process-global/hopperLeaseDescriptorPermissions.test.ts b/tests/process-global/hopperLeaseDescriptorPermissions.test.ts index 174097abe3..92a6e19e63 100644 --- a/tests/process-global/hopperLeaseDescriptorPermissions.test.ts +++ b/tests/process-global/hopperLeaseDescriptorPermissions.test.ts @@ -32,7 +32,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); -import { acquireLinuxHopperApplicationLease } from "../../src/hopper/HopperTargetLease.js"; +import { acquireLinuxHopperApplicationLease } from "../../src/process/HopperLease.js"; afterEach(() => { replacement.selected = ""; From f09f454f45d667a95b38be61ba6fde193bd08774 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 21:22:40 -0700 Subject: [PATCH 12/21] fix(native): retain valid Objective-C properties beside a malformed record (#3833) * fix(native): retain valid Objective-C properties beside a malformed record readObjcPropertyList decoded a whole property_list_t in one operation. When a later entry's name pointer was unmapped, the list-level catch discarded the properties already decoded. An empty property name passed decoding and then failed schema validation of the whole result, so inspect-native-dispatch-metadata exited 1 with no metadata. Each property_t entry is now decoded on its own. A failing entry, or an empty name, becomes a property_decode_failed issue at the entry address, and sibling properties are retained. Incomplete-attribute issues also report their entry address. List-header failures still fail only that list. Fixes #3336 Co-Authored-By: Claude Opus 5.5 * test(native): cover property record isolation on every platform Move the synthetic Objective-C property Mach-O into a shared fixture and decode it through decodeAppleDispatchMetadata in a portable test, so Linux and Windows runs also catch a regression that drops valid sibling properties. The macOS CLI cases keep exercising the built command. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/native-investigation.md | 5 +- src/native/AppleDispatchMetadata.test.ts | 33 +++++++ src/native/AppleObjcProperties.ts | 62 ++++++++---- .../nativeDispatchPropertyAttributes.test.ts | 94 ++++++++++--------- tests/fixtures/objcPropertyMacho.ts | 51 ++++++++++ 5 files changed, 181 insertions(+), 64 deletions(-) create mode 100644 tests/fixtures/objcPropertyMacho.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 3df66a93ff..ed9ffe46f0 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -380,7 +380,10 @@ complete observation. missing or incomplete attribute metadata leaves `atomicity` and any unestablished read-only value null. A type attribute whose quoted class name never closes is incomplete and marks `objc_properties_categories` partial - with `property_attributes_incomplete`. `is_meta_class`, `is_root_class`, and + with `property_attributes_incomplete`. A property record that cannot be + decoded, such as one with an empty or unmapped name, is reported as + `property_decode_failed` at its entry address while sibling properties are + retained. `is_meta_class`, `is_root_class`, and `ivar_count` are also nullable when the selected provider exposes only symbol names. Class implementation methods have null `is_required` and `is_optional` fields because those facts apply to protocol declarations; decoded protocol diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 4509e23732..f1af48e436 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { machoWithPropertyAttributes } from "../../tests/fixtures/objcPropertyMacho.js"; import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; const base = 0x100000000n; @@ -423,6 +424,38 @@ describe("Apple dispatch coverage ownership", () => { }); }); +describe("malformed Objective-C property records", () => { + it.each([ + ["an unmapped name", { secondNameOffset: 0x2000 }, "first", "0x100000518"], + ["an empty name", { firstName: "" }, "second", "0x100000508"], + ] as const)( + "retains the valid sibling of a property with %s", + (_, layout, kept, location) => { + const result = decodeAppleDispatchMetadata( + machoWithPropertyAttributes("Tq,N", layout), + 100, + provenance, + ); + expect( + result.objc_classes.map(({ name, properties }) => ({ + name, + properties: properties.map((property) => property.name), + })), + ).toEqual([{ name: "Fixture", properties: [kept] }]); + expect( + result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining( + `property_decode_failed at ${location}: Fixture: property`, + ), + }); + }, + ); +}); + describe("malformed Apple dispatch metadata", () => { it("retains valid class siblings when one class has an empty name", () => { const result = decodeAppleDispatchMetadata( diff --git a/src/native/AppleObjcProperties.ts b/src/native/AppleObjcProperties.ts index 5b78b5ab9f..266d5ead62 100644 --- a/src/native/AppleObjcProperties.ts +++ b/src/native/AppleObjcProperties.ts @@ -91,36 +91,62 @@ const splitAttributes = ( }; }; -/** Decode `property_list_t { entsize_and_flags, count, property_t[] }`. */ +/** One property-level decode problem, relative to the list's owner. */ +interface PropertyListIssue { + readonly code: "property_decode_failed" | "property_attributes_incomplete"; + readonly message: string; + readonly location: string; +} + +/** + * Decode `property_list_t { entsize_and_flags, count, property_t[] }`. Each + * entry is decoded on its own, so one malformed entry is reported without + * discarding the properties decoded before or after it. + */ export const readObjcPropertyList = ( read: ObjcPropertyReaders, list: bigint, admit: () => boolean, ): { readonly properties: ObjcProperty[]; - /** Names of properties whose attribute string is incomplete. */ - readonly incomplete: string[]; + readonly issues: PropertyListIssue[]; } => { - const incomplete: string[] = []; - if (list === 0n) return { properties: [], incomplete }; + const properties: ObjcProperty[] = []; + const issues: PropertyListIssue[] = []; + if (list === 0n) return { properties, issues }; const entrySize = read.u32(list) & ~3; const count = read.u32(list + 4n); if (entrySize !== 16) throw new TypeError( `Unsupported Objective-C property entry size ${entrySize}`, ); - const properties: ObjcProperty[] = []; for (let index = 0; index < count; index++) { if (!admit()) break; const entry = list + 8n + BigInt(index * entrySize); - const decoded = decodePropertyAttributes( - read.string(read.pointer(entry)), - read.string(read.pointer(entry + 8n)), - ); - properties.push(decoded.property); - if (!decoded.complete) incomplete.push(decoded.property.name); + const location = `0x${entry.toString(16)}`; + try { + const name = read.string(read.pointer(entry)); + if (name.length === 0) throw new TypeError("the property name is empty"); + const decoded = decodePropertyAttributes( + name, + read.string(read.pointer(entry + 8n)), + ); + properties.push(decoded.property); + if (!decoded.complete) + issues.push({ + code: "property_attributes_incomplete", + message: `property ${name} has an unterminated quoted class name in its type attribute, so atomicity and unestablished read-only state are unknown`, + location, + }); + } catch (cause: unknown) { + issues.push({ + code: "property_decode_failed", + message: `property ${String(index)} could not be decoded: ${cause instanceof Error ? cause.message : String(cause)}`, + location, + }); + } } - return { properties, incomplete }; + return { properties, issues }; }; /** Decode a property list, recording a failure for `owner` instead of throwing. */ @@ -135,18 +161,18 @@ export const readObjcPropertiesOf = ( }, ): ObjcProperty[] => { try { - const { properties, incomplete } = readObjcPropertyList( + const { properties, issues } = readObjcPropertyList( read, list, context.admit, ); - for (const name of incomplete) + for (const item of issues) context.issues.push( issue( "objc_properties_categories", - "property_attributes_incomplete", - `${context.owner}.${name}: the type attribute has an unterminated quoted class name, so atomicity and unestablished read-only state are unknown`, - context.location, + item.code, + `${context.owner}: ${item.message}`, + item.location, ), ); return properties; diff --git a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts index 51ab9dd4b6..7aa614afc4 100644 --- a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts +++ b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts @@ -3,54 +3,10 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; +import { machoWithPropertyAttributes } from "../../fixtures/objcPropertyMacho.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; -/** arm64 Mach-O whose two class properties share one attribute string. */ -const machoWithPropertyAttributes = (attributes: string): Buffer => { - const bytes = Buffer.alloc(4096); - const base = 0x100000000n; - const u32 = (at: number, value: number) => bytes.writeUInt32LE(value, at); - const pointer = (at: number, offset: number) => - bytes.writeBigUInt64LE(base + BigInt(offset), at); - u32(0, 0xfeedfacf); - u32(4, 0x0100000c); - u32(12, 2); - u32(16, 1); - u32(20, 152); - u32(32, 0x19); - u32(36, 152); - bytes.write("__DATA", 40); - pointer(56, 0); - bytes.writeBigUInt64LE(4096n, 64); - bytes.writeBigUInt64LE(4096n, 80); - u32(88, 5); - u32(92, 5); - u32(96, 1); - bytes.write("__objc_classlist", 104); - bytes.write("__DATA", 120); - pointer(136, 0x180); - bytes.writeBigUInt64LE(8n, 144); - u32(152, 0x180); - pointer(0x180, 0x200); - pointer(0x200 + 32, 0x300); - u32(0x300, 2); - u32(0x308, 16); - pointer(0x300 + 24, 0x400); - bytes.write("Fixture\0", 0x400); - pointer(0x340, 0x500); - u32(0x500, 16); - u32(0x504, 2); - pointer(0x508, 0x600); - pointer(0x510, 0x620); - pointer(0x518, 0x640); - pointer(0x520, 0x620); - bytes.write("first\0", 0x600); - bytes.write(`${attributes}\0`, 0x620); - bytes.write("second\0", 0x640); - return bytes; -}; - describe.skipIf(process.platform !== "darwin")( "native dispatch property attributes", () => { @@ -108,3 +64,51 @@ describe.skipIf(process.platform !== "darwin")( ); }, ); + +describe.skipIf(process.platform !== "darwin")( + "native dispatch property record isolation", + () => { + cliTest.for([ + [ + "an unmapped name pointer", + { secondNameOffset: 0x2000 }, + "first", + "0x100000518", + ], + ["an empty name", { firstName: "" }, "second", "0x100000508"], + ] as const)( + "retains the valid sibling of a property with $0", + async ([, layout, kept, location], { cli }) => { + const directory = await createTestTempDirectory("rea-objc-property-"); + const path = join(directory, "fixture.bin"); + await writeFile(path, machoWithPropertyAttributes("Tq,N", layout)); + const output = await cli.run({ + arguments: ["inspect-native-dispatch-metadata", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(output.exitCode).toBe(0); + expect(output.json).toMatchObject({ + normalized_result: { + result: { + objc_classes: [ + { + name: "Fixture", + properties: [{ name: kept, atomicity: "nonatomic" }], + }, + ], + coverage: expect.arrayContaining([ + expect.objectContaining({ + facet: "objc_properties_categories", + status: "partial", + reason: expect.stringContaining( + `property_decode_failed at ${location}: Fixture: property`, + ), + }), + ]), + }, + }, + }); + }, + ); + }, +); diff --git a/tests/fixtures/objcPropertyMacho.ts b/tests/fixtures/objcPropertyMacho.ts new file mode 100644 index 0000000000..49bfe66743 --- /dev/null +++ b/tests/fixtures/objcPropertyMacho.ts @@ -0,0 +1,51 @@ +/** + * arm64 Mach-O with one Objective-C class, `Fixture`, whose two properties + * `first` and `second` share one attribute string. The first name may be + * replaced and the second name pointer may target an unmapped offset. + */ +export const machoWithPropertyAttributes = ( + attributes: string, + { firstName = "first", secondNameOffset = 0x640 } = {}, +): Buffer => { + const bytes = Buffer.alloc(4096); + const base = 0x100000000n; + const u32 = (at: number, value: number) => bytes.writeUInt32LE(value, at); + const pointer = (at: number, offset: number) => + bytes.writeBigUInt64LE(base + BigInt(offset), at); + u32(0, 0xfeedfacf); + u32(4, 0x0100000c); + u32(12, 2); + u32(16, 1); + u32(20, 152); + u32(32, 0x19); + u32(36, 152); + bytes.write("__DATA", 40); + pointer(56, 0); + bytes.writeBigUInt64LE(4096n, 64); + bytes.writeBigUInt64LE(4096n, 80); + u32(88, 5); + u32(92, 5); + u32(96, 1); + bytes.write("__objc_classlist", 104); + bytes.write("__DATA", 120); + pointer(136, 0x180); + bytes.writeBigUInt64LE(8n, 144); + u32(152, 0x180); + pointer(0x180, 0x200); + pointer(0x200 + 32, 0x300); + u32(0x300, 2); + u32(0x308, 16); + pointer(0x300 + 24, 0x400); + bytes.write("Fixture\0", 0x400); + pointer(0x340, 0x500); + u32(0x500, 16); + u32(0x504, 2); + pointer(0x508, 0x600); + pointer(0x510, 0x620); + pointer(0x518, secondNameOffset); + pointer(0x520, 0x620); + bytes.write(`${firstName}\0`, 0x600); + bytes.write(`${attributes}\0`, 0x620); + bytes.write("second\0", 0x640); + return bytes; +}; From 089ef732e80e14252674dbb82e71c1189440a1d4 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 21:37:52 -0700 Subject: [PATCH 13/21] fix(apktool): own the version probe's process group and keep its timeout typed (#3808) * fix(apktool): own the version probe's process group and keep its timeout typed The version probe ran its launcher through execFileOutput, which waits for every inherited output pipe to close. A shell launcher's child that holds those pipes outlived the stop request, so the probe stayed pending past its deadline and its grace period, and cancellation hung the same way. An expired probe also surfaced as a non-retryable unusable-version refusal. Run the probe through runOwnedCommand like the decode run, so the owned process group is stopped with its children. Report its deadline as AnalysisTimeoutError with timeout_ms and retain an unresolved cleanup owner for close. Apktool launchers are interpreter scripts, so both the probe and the decode run prove ownership by run token instead of the launcher's argv prefix. A close with unresolved process cleanup reports a typed ProviderCleanupError instead of an untyped AggregateError. Fixes #3342 Fixes #3347 Fixes #3337 Co-Authored-By: Claude Opus 5.5 * fix(apktool): carry unresolved probe cleanup through both provider entry points A probe failure other than cancellation or timeout, such as an output overflow whose launcher child survives, now keeps its cleanup reason and resources on ApktoolConfigurationFailure. inspectAvailability and execute report it as ProviderCleanupError instead of ordinary unavailability or capability_unavailable. Add a real-process lifecycle test: an interpreter launcher starts a child that holds the inherited output pipes, the probe is cancelled after launch, and both processes must be stopped within the bound. Co-Authored-By: Claude Opus 5.5 * test(apktool): ignore zombies and always clean up the probe lifecycle fixture Treat a zombie as exited when checking the launcher and child, bound the cancellation wait so a regressed probe still reaches cleanup, and stop any remaining fixture processes in a finally block. Co-Authored-By: Claude Opus 5.5 * test(apktool): treat a failed process inspection as unknown, not stopped Only ESRCH, a zombie state or ps reporting no match counts as exited; a ps launch failure leaves the process presumed live so the assertion fails and the finally block still stops it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/apktool/ApktoolConfiguration.ts | 94 ++++++++--- src/apktool/ApktoolProvider.ts | 69 +++++++- .../ApktoolVersionProbe.lifecycle.test.ts | 107 +++++++++++++ src/apktool/ApktoolVersionProbe.test.ts | 147 ++++++++++++++++++ .../process-global/apktoolEnvironment.test.ts | 29 ++-- 5 files changed, 409 insertions(+), 37 deletions(-) create mode 100644 src/apktool/ApktoolVersionProbe.lifecycle.test.ts create mode 100644 src/apktool/ApktoolVersionProbe.test.ts diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 729c670e94..cacacf404b 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -8,10 +8,12 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; +import { randomUUID } from "node:crypto"; import { - execFileOutput, - execFileOutputFailure, -} from "../process/ExecFileOutput.js"; + OwnedCommandFailure, + runOwnedCommand, +} from "../process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { ProviderRejectionCode } from "../contracts/providerSelection.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -152,14 +154,23 @@ export type ApktoolConfigurationFailureCode = | "version_unresolved" | "unsupported_version"; +/** Unresolved cleanup of a launched probe; its owner was retained separately. */ +export interface ApktoolProbeCleanup { + readonly reason: string; + readonly resources: readonly string[]; +} + export class ApktoolConfigurationFailure extends Error { + readonly cleanup: ApktoolProbeCleanup | undefined; + constructor( readonly code: ApktoolConfigurationFailureCode, message: string, readonly diagnostics: Readonly> = {}, - options?: ErrorOptions, + options?: ErrorOptions & { readonly cleanup?: ApktoolProbeCleanup }, ) { super(message, options); + this.cleanup = options?.cleanup; } } @@ -228,6 +239,7 @@ export const inspectApktoolClient = async ( signal?: AbortSignal, environment: Readonly> = process.env, platform: NodeJS.Platform = process.platform, + onCleanupOwner?: (owner: ProviderProcessSupervisor) => void, ): Promise => { const hostEnvironment = snapshotEnvironment(environment, platform); let invocation: ApktoolInvocation; @@ -254,23 +266,57 @@ export const inspectApktoolClient = async ( let stdout: string; let stderr: string; try { - ({ stdout, stderr } = await execFileOutput( - invocation.command, - invocation.arguments, + // The owned process group stops a shell launcher's children with it; a + // child holding inherited output pipes cannot outlive the deadline. + const output = await runOwnedCommand( { + command: invocation.command, + arguments: invocation.arguments, windowsVerbatimArguments: invocation.windowsVerbatimArguments, - timeout: VERSION_TIMEOUT_MS, - stopSignal: "SIGTERM", - maxBuffer: VERSION_MAX_OUTPUT_BYTES, - env: hostEnvironment, - ...(signal === undefined ? {} : { signal }), + // Apktool launchers are commonly interpreter scripts, so ownership is + // proved by the run token rather than the launcher's argv prefix. + expectedCommand: null, + runId: randomUUID(), + hostEnvironment, }, - )); + { + timeoutMs: VERSION_TIMEOUT_MS, + diagnosticBytes: VERSION_MAX_OUTPUT_BYTES, + }, + signal === undefined ? {} : { signal }, + ); + stdout = output.stdout.text; + stderr = output.stderr.text; } catch (cause) { - const failure = execFileOutputFailure(cause); - if (signal?.aborted || failure?.code === "ABORT_ERR") - throw new AnalysisCancelledError("inspect_apktool_client", { cause }); - if (failure?.code === "ENOENT") + if ( + cause instanceof OwnedCommandFailure && + cause.cleanupOwner !== undefined + ) + onCleanupOwner?.(cause.cleanupOwner); + const cleanup = + cause instanceof OwnedCommandFailure && cause.cleanupFailure !== null + ? { reason: cause.cleanupFailure, resources: cause.resources } + : undefined; + const cleanupDetails = cleanup === undefined ? {} : { cleanup }; + if ( + signal?.aborted === true || + (cause instanceof OwnedCommandFailure && cause.reason === "cancelled") + ) + throw new AnalysisCancelledError("inspect_apktool_client", { + cause, + ...cleanupDetails, + }); + if (cause instanceof OwnedCommandFailure && cause.reason === "timeout") + throw new AnalysisTimeoutError( + "inspect_apktool_client", + VERSION_TIMEOUT_MS, + { cause, ...cleanupDetails }, + ); + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "EACCES") + ) throw new ApktoolConfigurationFailure( "command_missing", `The selected apktool launcher was not found: ${selection.command}`, @@ -281,19 +327,25 @@ export const inspectApktoolClient = async ( }, { cause }, ); + const snapshot = + cause instanceof OwnedCommandFailure ? cause.snapshot : null; throw new ApktoolConfigurationFailure( "version_unresolved", "The selected apktool launcher did not report a usable version", { selected_command: selection.command, - ...(failure === undefined + ...(snapshot === null ? {} : { - exit_code: failure.code, - stderr_excerpt: failure.stderr.slice(0, 512), + exit_code: snapshot.exitCode ?? null, + stderr_excerpt: snapshot.stderr.text.slice(0, 512), }), + ...(cause instanceof OwnedCommandFailure && + cause.reason === "output-limit" + ? { max_output_bytes: VERSION_MAX_OUTPUT_BYTES } + : {}), }, - { cause }, + { cause, ...cleanupDetails }, ); } const identity = parseApktoolVersionOutput(stdout); diff --git a/src/apktool/ApktoolProvider.ts b/src/apktool/ApktoolProvider.ts index eec08fe433..2fa61fe5fd 100644 --- a/src/apktool/ApktoolProvider.ts +++ b/src/apktool/ApktoolProvider.ts @@ -56,6 +56,7 @@ import { WindowsBatchCommandError } from "../process/WindowsBatchCommand.js"; import { APKTOOL_COMMAND_ENV, ApktoolConfigurationFailure, + type ApktoolProbeCleanup, apktoolConfigurationAvailability, apktoolInvocation, inspectApktoolClient, @@ -148,6 +149,19 @@ interface DecodeLimits { readonly maxOutputBytes: number; } +/** A probe whose launched processes could not be confirmed stopped. */ +const probeCleanupFailure = ( + operation: string, + failure: ApktoolConfigurationFailure, + cleanup: ApktoolProbeCleanup, +): ProviderCleanupError => + new ProviderCleanupError( + APKTOOL_PROVIDER_IDENTITY.id, + cleanup.resources, + { reason: cleanup.reason, probe_failure: failure.message }, + { operation, cause: failure }, + ); + const runApktool = async ( operation: string, selection: ApktoolCommandSelection, @@ -180,6 +194,9 @@ const runApktool = async ( command: invocation.command, arguments: invocation.arguments, windowsVerbatimArguments: invocation.windowsVerbatimArguments, + // Apktool launchers are commonly interpreter scripts, so ownership is + // proved by the run token rather than the launcher's argv prefix. + expectedCommand: null, runId: randomUUID(), hostEnvironment: environment, }, @@ -328,6 +345,10 @@ export class ApktoolProvider { private readonly workspaces = new Set(); private readonly removeWorkspace: (path: string) => Promise; readonly #pendingCommandOwners = new Map(); + /** A version probe whose cleanup is unresolved is retried on close. */ + readonly #retainProbeOwner = (owner: ProviderProcessSupervisor): void => { + this.#pendingCommandOwners.set(owner, "apktool version probe"); + }; readonly #shutdown = new AbortController(); readonly #active = new Set>(); #closing = false; @@ -378,7 +399,13 @@ export class ApktoolProvider { process.platform, signal, ); - await inspectApktoolClient(selection, signal, environment); + await inspectApktoolClient( + selection, + signal, + environment, + process.platform, + this.#retainProbeOwner, + ); throwIfCancelled("inspect_apktool_client", signal); return { status: "available", @@ -390,8 +417,15 @@ export class ApktoolProvider { }, }; } catch (cause) { - if (cause instanceof ApktoolConfigurationFailure) + if (cause instanceof ApktoolConfigurationFailure) { + if (cause.cleanup !== undefined) + throw probeCleanupFailure( + "inspect_apktool_client", + cause, + cause.cleanup, + ); return apktoolConfigurationAvailability(cause); + } throw cause; } } @@ -437,7 +471,13 @@ export class ApktoolProvider { process.platform, signal, ); - identity = await inspectApktoolClient(selection, signal, environment); + identity = await inspectApktoolClient( + selection, + signal, + environment, + process.platform, + this.#retainProbeOwner, + ); } catch (cause) { if ( cause instanceof AnalysisCancelledError || @@ -445,6 +485,10 @@ export class ApktoolProvider { ) return err(cause); if (cause instanceof ApktoolConfigurationFailure) { + if (cause.cleanup !== undefined) + return err( + probeCleanupFailure(request.operation, cause, cause.cleanup), + ); return err( providerConfigurationRefusal( APKTOOL_PROVIDER_IDENTITY.id, @@ -497,12 +541,14 @@ export class ApktoolProvider { async #drainAndClean(): Promise { await Promise.all(this.#active); const failures: unknown[] = []; + const retained: string[] = []; for (const [owner, root] of this.#pendingCommandOwners) { try { const stopped = await owner.stop(); if (stopped.status === "incomplete") throw new Error(stopped.reason); this.#pendingCommandOwners.delete(owner); } catch (cause) { + retained.push(root); failures.push( new Error( `apktool process cleanup remains incomplete for ${root}: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -511,8 +557,23 @@ export class ApktoolProvider { ); } } + // Ownership stays retained for a later close; report it as typed cleanup. if (failures.length > 0) - throw new AggregateError(failures, "Apktool process cleanup failed"); + throw new ProviderCleanupError( + APKTOOL_PROVIDER_IDENTITY.id, + retained, + { + reason: failures + .map((failure) => + failure instanceof Error ? failure.message : String(failure), + ) + .join("; "), + }, + { + operation: "close", + cause: new AggregateError(failures, "Apktool process cleanup failed"), + }, + ); for (const path of this.workspaces) { try { await this.removeWorkspace(path); diff --git a/src/apktool/ApktoolVersionProbe.lifecycle.test.ts b/src/apktool/ApktoolVersionProbe.lifecycle.test.ts new file mode 100644 index 0000000000..6abe196495 --- /dev/null +++ b/src/apktool/ApktoolVersionProbe.lifecycle.test.ts @@ -0,0 +1,107 @@ +import { execFile } from "node:child_process"; +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; + +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { inspectApktoolClient } from "./ApktoolConfiguration.js"; + +/** + * Only an exited process counts as stopped: it is gone, a zombie waiting to + * be reaped, or absent from `ps`. A failed inspection is not proof of exit. + */ +const alive = async (pid: number): Promise => { + try { + process.kill(pid, 0); + } catch (cause: unknown) { + if ((cause as NodeJS.ErrnoException).code === "ESRCH") return false; + return true; + } + return promisify(execFile)("ps", ["-o", "stat=", "-p", `${pid}`]).then( + ({ stdout }) => { + const state = stdout.trim(); + return state !== "" && !state.startsWith("Z"); + }, + // `ps` exits 1 when no process matches; any other failure is unknown. + (cause: unknown) => (cause as { code?: unknown }).code !== 1, + ); +}; + +const waitFor = async (check: () => Promise, ms: number) => { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (await check()) return true; + await delay(25); + } + return false; +}; + +const readPids = async (path: string): Promise => + readFile(path, "utf8") + .then((text) => JSON.parse(text) as number[]) + .catch(() => []); + +// The interpreter is the test runtime itself, so its environment (and the +// ownership run token) is readable on both Linux and macOS. +it.skipIf(process.platform === "win32")( + "stops an interpreter launcher and its pipe-holding child when the probe is cancelled", + async () => { + const root = await createTestTempDirectory("rea-apktool-probe-"); + const pids = join(root, "pids.json"); + const launcher = join(root, "apktool"); + await writeFile( + launcher, + [ + `#!${process.execPath}`, + 'const { spawn } = require("node:child_process");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit" });', + `require("node:fs").writeFileSync(${JSON.stringify(pids)}, JSON.stringify([process.pid, child.pid]));`, + "setInterval(() => {}, 1000);", + ].join("\n"), + ); + await chmod(launcher, 0o755); + const controller = new AbortController(); + const started = performance.now(); + const probe = inspectApktoolClient( + { command: launcher, commandSource: "environment" }, + controller.signal, + process.env, + ).catch((cause: unknown) => cause); + try { + expect( + await waitFor(async () => (await readPids(pids)).length === 2, 10_000), + ).toBe(true); + const launched = await readPids(pids); + controller.abort(); + // A regressed probe never settles; bound the wait so cleanup still runs. + const outcome = await Promise.race([ + probe, + delay(15_000).then(() => "still pending"), + ]); + expect(outcome).toBeInstanceOf(AnalysisCancelledError); + expect(performance.now() - started).toBeLessThan(15_000); + expect( + await waitFor( + async () => + (await Promise.all(launched.map(alive))).every((live) => !live), + 5_000, + ), + ).toBe(true); + } finally { + // A failed assertion must not leave the fixture's processes running. + controller.abort(); + await Promise.race([probe, delay(10_000)]); + for (const pid of await readPids(pids)) + if (await alive(pid)) + try { + process.kill(pid, "SIGKILL"); + } catch { + // Already exited between the check and the signal. + } + } + }, + 30_000, +); diff --git a/src/apktool/ApktoolVersionProbe.test.ts b/src/apktool/ApktoolVersionProbe.test.ts new file mode 100644 index 0000000000..616157ba6b --- /dev/null +++ b/src/apktool/ApktoolVersionProbe.test.ts @@ -0,0 +1,147 @@ +import { afterEach, expect, it, vi } from "vitest"; + +import { + AnalysisCancelledError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../process/OwnedCommand.js"; +import type { ProviderProcessSupervisor } from "../process/ProviderProcess.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + ApktoolConfigurationFailure, + inspectApktoolClient, +} from "./ApktoolConfiguration.js"; +import { ApktoolProvider } from "./ApktoolProvider.js"; + +vi.mock("../process/OwnedCommand.js", async (importOriginal) => ({ + ...(await importOriginal()), + runOwnedCommand: vi.fn(), +})); +afterEach(() => vi.mocked(runOwnedCommand).mockReset()); + +const selection = { + command: "/opt/apktool/apktool", + commandSource: "environment", +} as const; +const owner = {} as ProviderProcessSupervisor; + +it("runs the version probe as an owned interpreter-script process group", async () => { + vi.mocked(runOwnedCommand).mockResolvedValueOnce({ + stdout: { text: "2.9.3\n" }, + stderr: { text: "" }, + } as Awaited>); + await expect( + inspectApktoolClient(selection, undefined, {}, "linux"), + ).resolves.toEqual({ version: "2.9.3" }); + expect(vi.mocked(runOwnedCommand).mock.lastCall?.[0]).toMatchObject({ + command: "/opt/apktool/apktool", + arguments: ["--version"], + expectedCommand: null, + }); + expect(vi.mocked(runOwnedCommand).mock.lastCall?.[1]).toEqual({ + timeoutMs: 20_000, + diagnosticBytes: 64 * 1024, + }); +}); + +it("reports an expired version probe as a typed timeout", async () => { + vi.mocked(runOwnedCommand).mockRejectedValueOnce( + new OwnedCommandFailure("timeout", "Command deadline elapsed."), + ); + const failure = await inspectApktoolClient( + selection, + undefined, + {}, + "linux", + ).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(AnalysisTimeoutError); + expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({ + code: "provider_timeout", + category: "timeout", + details: { timeout_ms: 20_000 }, + }); +}); + +it("retains an unresolved probe cleanup owner and reports the cleanup", async () => { + vi.mocked(runOwnedCommand).mockRejectedValueOnce( + new OwnedCommandFailure( + "timeout", + "Command deadline elapsed.", + null, + "cleanup incomplete", + undefined, + ["pid:1"], + owner, + ), + ); + const retained: ProviderProcessSupervisor[] = []; + const failure = await inspectApktoolClient( + selection, + undefined, + {}, + "linux", + (cleanupOwner) => retained.push(cleanupOwner), + ).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(AnalysisTimeoutError); + expect(projectAnalysisError(failure as AnalysisTimeoutError)).toMatchObject({ + code: "cleanup_incomplete", + }); + expect(retained).toEqual([owner]); +}); + +it("reports a cancelled version probe as cancelled", async () => { + vi.mocked(runOwnedCommand).mockRejectedValueOnce( + new OwnedCommandFailure("cancelled", "Command cancelled."), + ); + await expect( + inspectApktoolClient(selection, undefined, {}, "linux"), + ).rejects.toBeInstanceOf(AnalysisCancelledError); +}); + +it("keeps unresolved probe cleanup typed when the launcher overflows its output", async () => { + const overflow = () => + new OwnedCommandFailure( + "output-limit", + "Command diagnostic output exceeded its complete-output budget.", + null, + "process group still running", + undefined, + ["pid:7", "process-group:7"], + owner, + ); + vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow()); + const failure = await inspectApktoolClient( + selection, + undefined, + {}, + "linux", + ).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ApktoolConfigurationFailure); + expect((failure as ApktoolConfigurationFailure).cleanup).toEqual({ + reason: "process group still running", + resources: ["pid:7", "process-group:7"], + }); + + // Both provider entry points report the unresolved processes as cleanup. + const provider = new ApktoolProvider({ + environment: { REA_APKTOOL_COMMAND: "/opt/apktool/apktool" }, + }); + vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow()); + await expect(provider.inspectAvailability()).rejects.toBeInstanceOf( + ProviderCleanupError, + ); + vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow()); + const result = await provider.execute({ + operation: "decode_android_resources", + input: {}, + } as never); + expect(result.ok ? null : result.error).toBeInstanceOf(ProviderCleanupError); + expect(result.ok ? null : projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { resources: ["pid:7", "process-group:7"] }, + }); +}); diff --git a/tests/process-global/apktoolEnvironment.test.ts b/tests/process-global/apktoolEnvironment.test.ts index 90f4d483c4..876b05aa5a 100644 --- a/tests/process-global/apktoolEnvironment.test.ts +++ b/tests/process-global/apktoolEnvironment.test.ts @@ -1,18 +1,26 @@ import { afterEach, expect, it, onTestFinished, vi } from "vitest"; import { ApktoolProvider } from "../../src/apktool/ApktoolProvider.js"; -import { execFileOutput } from "../../src/process/ExecFileOutput.js"; +import { runOwnedCommand } from "../../src/process/OwnedCommand.js"; +import type { ProviderProcessSnapshot } from "../../src/process/ProviderProcess.js"; -vi.mock("../../src/process/ExecFileOutput.js", async (importOriginal) => ({ +vi.mock("../../src/process/OwnedCommand.js", async (importOriginal) => ({ ...(await importOriginal< - typeof import("../../src/process/ExecFileOutput.js") + typeof import("../../src/process/OwnedCommand.js") >()), - execFileOutput: vi.fn(async () => ({ stdout: "2.7.0-stub\n", stderr: "" })), + runOwnedCommand: vi.fn( + async () => + ({ + stdout: { text: "2.7.0-stub\n" }, + stderr: { text: "" }, + }) as unknown as ProviderProcessSnapshot, + ), })); afterEach(() => { vi.unstubAllEnvs(); - vi.mocked(execFileOutput).mockClear(); + vi.mocked(runOwnedCommand).mockClear(); }); +const lastSpawn = () => vi.mocked(runOwnedCommand).mock.lastCall?.[0]; it.skipIf(process.platform === "win32")( "keeps an explicit environment's PATH choice and samples ambient inheritance per operation", @@ -24,16 +32,15 @@ it.skipIf(process.platform === "win32")( vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/changed-apktool"); vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", "changed"); await provider.inspectAvailability(); - expect(vi.mocked(execFileOutput).mock.lastCall?.[0]).toBe("apktool"); - expect(vi.mocked(execFileOutput).mock.lastCall?.[2]?.env).toMatchObject({ + expect(lastSpawn()?.command).toBe("apktool"); + expect(lastSpawn()?.hostEnvironment).toMatchObject({ REA_APKTOOL_COMMAND: undefined, REA_APKTOOL_TEST_INHERITED_VALUE: "changed", }); vi.stubEnv("REA_APKTOOL_TEST_INHERITED_VALUE", undefined); await provider.inspectAvailability(); expect( - vi.mocked(execFileOutput).mock.lastCall?.[2]?.env - ?.REA_APKTOOL_TEST_INHERITED_VALUE, + lastSpawn()?.hostEnvironment?.REA_APKTOOL_TEST_INHERITED_VALUE, ).toBeUndefined(); }, ); @@ -46,8 +53,6 @@ it.skipIf(process.platform === "win32")( onTestFinished(() => provider.close()); vi.stubEnv("REA_APKTOOL_COMMAND", "/ambient/later-apktool"); await provider.inspectAvailability(); - expect(vi.mocked(execFileOutput).mock.lastCall?.[0]).toBe( - "/ambient/later-apktool", - ); + expect(lastSpawn()?.command).toBe("/ambient/later-apktool"); }, ); From 0323ce0a287ceead80cb83afc1e96c3f125bb82b Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 21:39:26 -0700 Subject: [PATCH 14/21] fix(javascript): keep the request endpoint on a uniquely consumed response (#3822) * fix(javascript): keep the request endpoint on a uniquely consumed response collectResponseConsumers always produced response-consumer operations with endpoint null, so a json()/text() consumer that resolved to exactly one request lost the request's literal endpoint in the semantic graph. When the consumer resolves to one request through that request's own result binding, the same condition that already makes it complete, it now carries that request's endpoint. Ambiguous consumers stay null. Fixes #3328 Co-Authored-By: Claude Opus 5.5 * fix(javascript): drop the endpoint when another write can replace the response Copy the request's endpoint only when the request is the binding's sole write; any other assignment, update, loop or destructuring target can replace the response before the consumer reads it. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...emanticAnalysis.response_endpoints.test.ts | 59 +++++++++++++++++++ .../javascriptSemanticDataEffects.ts | 22 ++++--- 2 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts new file mode 100644 index 0000000000..425653e6f5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.response_endpoints.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const responses = (source: string) => + analyzeJavaScriptSemantics(source) + .requestOperations.filter(({ kind }) => kind === "response-consumer") + .map(({ method, endpoint, resolution }) => ({ + method, + endpoint, + resolution, + })); + +it("keeps the endpoint of a uniquely consumed request response", () => { + expect( + responses(` + async function load() { + const a = await fetch("https://example.test/a"); + const b = await fetch("https://example.test/b"); + return [await a.json(), await b.text()]; + } + `), + ).toEqual([ + { + method: "json", + endpoint: "https://example.test/a", + resolution: "complete", + }, + { + method: "text", + endpoint: "https://example.test/b", + resolution: "complete", + }, + ]); +}); + +it("leaves an ambiguous response consumer without an endpoint", () => { + expect( + responses(` + async function load(flag) { + let response = await fetch("https://example.test/a"); + if (flag) response = await fetch("https://example.test/b"); + return response.json(); + } + `), + ).toEqual([{ method: "json", endpoint: null, resolution: "partial" }]); +}); + +it("leaves a response replaced by a non-request value without an endpoint", () => { + expect( + responses(` + async function load(cachedResponse) { + let response = await fetch("https://example.test/a"); + response = cachedResponse; + return response.json(); + } + `), + ).toEqual([expect.objectContaining({ method: "json", endpoint: null })]); +}); diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index c6d47dfd21..6d1de8325e 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -270,6 +270,18 @@ const collectResponseConsumers = ( ? [] : (requestsByBinding.get(binding.bindingId) ?? []); if (linked.length === 0) return; + // A consumer of exactly one request through its own result binding + // reads that request's response, so it keeps the request's endpoint. + const uniqueRequest = + linked.length === 1 && + !linked[0]?.operation.projectedResultBindings.some( + ({ bindingId }) => bindingId === binding?.bindingId, + ) + ? linked[0] + : undefined; + // Any other write (assignment, update, loop or destructuring target) can + // replace the response before this read, so its endpoint stays unknown. + const soleWrite = binding?.initializers.length === 1; output.push({ requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind: "response-consumer", @@ -287,15 +299,9 @@ const collectResponseConsumers = ( linkedRequestIds: linked .map(({ operation }) => operation.requestId) .sort(compareUnicodeCodePoints), - endpoint: null, + endpoint: soleWrite ? (uniqueRequest?.operation.endpoint ?? null) : null, fields: [], - resolution: - linked.length === 1 && - !linked[0]?.operation.projectedResultBindings.some( - ({ bindingId }) => bindingId === binding?.bindingId, - ) - ? "complete" - : "partial", + resolution: uniqueRequest === undefined ? "partial" : "complete", }); }); return output; From acca91d22b3406ee87a2aed17cb9f02de0f83185 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 21:39:39 -0700 Subject: [PATCH 15/21] fix(javascript): keep members unknown after a write through a local setter (#3831) * fix(javascript): keep members unknown after a write through a local setter Assigning to a key that a setter of the binding's own object literal defines runs that setter, which may replace any member of its receiver. The member-mutation collector recorded it as an ordinary write to that one key, so sibling literals such as value: 1 stayed known and export-shape comparisons reported complete, unchanged shapes. Record such a write with an unknown key, which invalidates the object's known members. A computed setter key may match any written key. Fixes #3312 Co-Authored-By: Claude Opus 5.5 * fix(javascript): follow nested setters, skip deletes and cache setter keys Resolve the written receiver along nested object-literal members so a write through a nested setter widens that receiver's members. A delete never calls a setter, so it keeps its exact path. Setter keys are computed once per object literal, including the no-setter result. Co-Authored-By: Claude Opus 5.5 * fix(javascript): ignore setters replaced by a later data property Build cached setter keys from the effective definitions: a later data property or method with the same static name replaces the accessor, so assigning that key no longer runs a setter. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...ptSemanticAnalysis.accessor_writes.test.ts | 108 ++++++++++++++++++ .../javascriptSemanticMemberMutations.ts | 86 ++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts new file mode 100644 index 0000000000..1b52c87fa7 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts @@ -0,0 +1,108 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const returned = (source: string) => { + const ir = analyzeJavaScriptSemantics(source); + const callable = ir.callables.find(({ name }) => name === "observe"); + return callable?.returnSites[0]?.value; +}; + +it("keeps members unknown after a write through a local setter", () => { + // The setter runs on assignment and may replace any member of `this`. + expect( + returned(` + export function observe() { + const state = { value: 1, set update(next) { this.value = next; } }; + state.update = 2; + return state; + } + `), + ).toMatchObject({ status: "unknown" }); +}); + +it("keeps sibling literals when the written key is an ordinary property", () => { + expect( + returned(` + export function observe() { + const state = { value: 1, update: 0 }; + state.update = 2; + return state; + } + `), + ).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "value", + value: { status: "literal", value: 1 }, + }), + ]), + }); +}); + +it("keeps nested members unknown after a write through a nested setter", () => { + const value = returned(` + export function observe() { + const state = { + label: "kept", + nested: { value: 1, set update(next) { this.value = next; } }, + }; + state.nested.update = 2; + return state; + } + `); + expect(value).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "label", + value: { status: "literal", value: "kept" }, + }), + expect.objectContaining({ + name: "nested", + value: expect.objectContaining({ status: "unknown" }), + }), + ]), + }); +}); + +it("keeps sibling literals when an accessor is deleted rather than assigned", () => { + expect( + returned(` + export function observe() { + const state = { value: 1, set update(next) {} }; + delete state.update; + return state; + } + `), + ).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "value", + value: { status: "literal", value: 1 }, + }), + ]), + }); +}); + +it("keeps sibling literals when a later data property replaces the setter", () => { + expect( + returned(` + export function observe() { + const state = { value: 1, set update(next) {}, update: 0 }; + state.update = 2; + return state; + } + `), + ).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "value", + value: { status: "literal", value: 1 }, + }), + ]), + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 24535f91eb..ce7310e367 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -53,6 +53,82 @@ type PropertyPath = JavaScriptSemanticPropertyPath; type ValueEffect = "write" | "escape"; /** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */ + +/** Setter keys of one object literal; a computed setter key may match any key. */ +interface SetterKeys { + readonly names: ReadonlySet; + readonly computed: boolean; +} + +// Writes to one literal repeat; scan its properties once, including the +// common result that it defines no setter. +const setterKeysByObject = new WeakMap(); + +const setterKeys = (object: t.ObjectExpression): SetterKeys | null => { + const cached = setterKeysByObject.get(object); + if (cached !== undefined) return cached; + const names = new Set(); + let computed = false; + // Follow definition order: a later data property or method with the same + // static name replaces the accessor, so its setter no longer runs. Spreads + // and unknown computed keys may or may not replace it and keep it. + for (const property of object.properties) { + if (t.isSpreadElement(property)) continue; + const name = semanticStaticPropertyKey(property.key, property.computed); + if (t.isObjectMethod(property) && property.kind === "set") { + if (name === null) computed = true; + else names.add(name); + } else if ( + name !== null && + !(t.isObjectMethod(property) && property.kind === "get") + ) + names.delete(name); + } + const keys = names.size === 0 && !computed ? null : { names, computed }; + setterKeysByObject.set(object, keys); + return keys; +}; + +/** + * The receiver path whose own object-literal setter an assignment to `path` + * runs, following nested literal members from the binding's initializer. + */ +const localSetterReceiver = ( + initializers: readonly { + readonly node: t.Node; + readonly projection: readonly unknown[]; + }[], + path: readonly unknown[], +): readonly string[] | undefined => { + const key = path.at(-1); + if (typeof key !== "string") return undefined; + const receiver = path.slice(0, -1); + if (!receiver.every((segment) => typeof segment === "string")) + return undefined; + for (const { node, projection } of initializers) { + if (projection.length !== 0) continue; + let object: t.Node | undefined = unwrapJavaScriptExpression(node).node; + for (const segment of receiver) { + if (!t.isObjectExpression(object)) break; + const member: t.ObjectExpression["properties"][number] | undefined = + object.properties.findLast( + (property) => + t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === + segment, + ); + object = t.isObjectProperty(member) + ? unwrapJavaScriptExpression(member.value).node + : undefined; + } + if (!t.isObjectExpression(object)) continue; + const keys = setterKeys(object); + if (keys !== null && (keys.computed || keys.names.has(key))) + return receiver as readonly string[]; + } + return undefined; +}; + export function* collectSemanticMemberMutationsSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, @@ -377,6 +453,16 @@ export function* collectSemanticMemberMutationsSteps( current.originAt, parents, ); + // A setter on the binding's own literal runs on assignment and may + // replace any member of its receiver, so the written key is unknown. + // Deleting the accessor never calls it. + if ( + effect === "write" && + !t.isUnaryExpression(mutation, { operator: "delete" }) + ) { + const receiver = localSetterReceiver(origins.initializers, path); + if (receiver !== undefined) path = [...receiver, null]; + } // Keep an antichain of exact paths. Widening distinct member keys would // also invalidate unrelated siblings that never reach this effect. // This map is local to the file scan. Escaping the binding id keeps a From 788964d52c62aa5b29b342e31b37fe63a27d69d1 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 21:44:14 -0700 Subject: [PATCH 16/21] perf(javascript): index object literal members for exact-key reference lookup (#3836) semanticObjectPropertyReferences scanned every property of an object literal on each call, and member-mutation tracking calls it once per write, so N writes to an N-property literal cost O(N^2). For an exact string or number key, a property with another static name only updates override bookkeeping for that other name, which the key never reads. Index each literal once by static name, keeping spreads, computed keys and __proto__ data entries for every key, and walk only the relevant members in reverse source order. Wildcard and selection keys keep the full scan. Co-authored-by: Claude Opus 5.5 --- ...javascriptSemanticObjectReferences.test.ts | 73 +++++++++++++++++++ .../javascriptSemanticObjectReferences.ts | 71 +++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/domain/javascript/javascriptSemanticObjectReferences.test.ts diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.test.ts b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts new file mode 100644 index 0000000000..f9867ed197 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts @@ -0,0 +1,73 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; +import { semanticObjectPropertyReferences } from "./javascriptSemanticObjectReferences.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const objectAndState = (source: string) => { + const parsed = parseJavaScriptSource(source, "object.js"); + if (parsed === null) throw new Error("Expected a parsed fixture"); + const declaration = parsed.program.body[0]; + if (!t.isVariableDeclaration(declaration)) + throw new Error("Expected a declaration"); + const init = declaration.declarations[0]?.init; + if (!t.isObjectExpression(init)) throw new Error("Expected an object"); + return { + object: init, + source, + state: completeSemanticSteps(indexJavaScriptBindingsSteps(parsed.program)), + }; +}; + +const spelled = ( + fixture: ReturnType, + key: string | number | null, +) => + semanticObjectPropertyReferences(fixture.object, [key], fixture.state).map( + ({ node, path }) => [ + fixture.source.slice(node.start ?? 0, node.end ?? 0), + JSON.stringify(path), + ], + ); + +it("resolves exact keys with source-order overrides, spreads and accessors", () => { + const fixture = objectAndState( + `const o = { a: 1, ...base, b: 2, a: 3, [dynamic]: 4, __proto__: proto, ...{ b: 5 }, get c() { return 6; }, c: 7 };`, + ); + // Expectations match the full source-order scan these exact keys replace. + expect(spelled(fixture, "a")).toEqual([ + ["{ b: 5 }", '["a"]'], + ["4", "[]"], + ["3", "[]"], + ]); + expect(spelled(fixture, "b")).toEqual([ + ["{ b: 5 }", '["b"]'], + ["4", "[]"], + ]); + expect(spelled(fixture, "c")).toEqual([ + ["7", "[]"], + ["4", "[]"], + ]); + expect(spelled(fixture, "missing")).toEqual([ + ["{ b: 5 }", '["missing"]'], + ["proto", '["missing"]'], + ["4", "[]"], + ["base", '["missing"]'], + ]); + // A wildcard key still scans every property. + expect(spelled(fixture, null)).toEqual([ + ["7", "[]"], + ["{ b: 5 }", '[{"excludedKeys":["c"]}]'], + ["proto", "[null]"], + ["4", "[]"], + ["3", "[]"], + ["2", "[]"], + ["base", '[{"excludedKeys":["a","b","c"]}]'], + ]); + // Numeric keys use the same exact spelling as string keys. + expect( + spelled(objectAndState(`const o = { 1: "one", 2: "two" };`), 1), + ).toEqual([['"one"', "[]"]]); +}); diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 2e83131675..111417b5a7 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -739,6 +739,75 @@ const getterResultReferences = ( return references; }; +type ObjectLiteralMember = t.ObjectExpression["properties"][number]; + +/** Property positions grouped by whether an exact key can reach them. */ +interface ObjectPropertyIndex { + /** Spreads, computed keys and `__proto__` data entries reach every key. */ + readonly keyIndependent: readonly number[]; + readonly byName: ReadonlyMap; +} + +// Mutation tracking resolves one key of the same literal per write, so the +// index removes the per-write scan of every property. +const propertyIndexes = new WeakMap(); + +const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => { + const cached = propertyIndexes.get(node); + if (cached !== undefined) return cached; + const keyIndependent: number[] = []; + const byName = new Map(); + node.properties.forEach((property, index) => { + const name = t.isSpreadElement(property) + ? null + : semanticStaticPropertyKey(property.key, property.computed); + if ( + name === null || + (t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__") + ) { + keyIndependent.push(index); + return; + } + const positions = byName.get(name); + if (positions === undefined) byName.set(name, [index]); + else positions.push(index); + }); + const index = { keyIndependent, byName }; + propertyIndexes.set(node, index); + return index; +}; + +/** + * Properties in reverse source order that can affect `key`. For an exact key, + * another static name only updates bookkeeping for that other name, which + * this key never reads, so only same-name and key-independent entries remain. + */ +const relevantPropertiesInReverse = ( + node: t.ObjectExpression, + key: PropertyPath[number] | undefined, +): readonly ObjectLiteralMember[] => { + if (typeof key !== "string" && typeof key !== "number") + return [...node.properties].reverse(); + const { keyIndependent, byName } = objectPropertyIndex(node); + const named = byName.get(String(key)) ?? []; + const relevant: ObjectLiteralMember[] = []; + let left = keyIndependent.length - 1; + let right = named.length - 1; + while (left >= 0 || right >= 0) { + const fromLeft = + right < 0 || + (left >= 0 && (keyIndependent[left] ?? -1) > (named[right] ?? -1)); + const position = fromLeft ? keyIndependent[left--] : named[right--]; + const property = + position === undefined ? undefined : node.properties[position]; + if (property !== undefined) relevant.push(property); + } + return relevant; +}; + export const semanticObjectPropertyReferences = ( node: t.ObjectExpression, path: PropertyPath, @@ -751,7 +820,7 @@ export const semanticObjectPropertyReferences = ( const accessorNames = new Set(); const getterNames = new Set(); const setterNames = new Set(); - for (const property of [...node.properties].reverse()) { + for (const property of relevantPropertiesInReverse(node, key)) { if (t.isSpreadElement(property)) { const selected = key ?? null; if (typeof selected !== "object" && overwritten.has(String(selected))) From 034342ba28c8e56c3746be92b78aa7299afa02bd Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 22:02:45 -0700 Subject: [PATCH 17/21] fix(reference): select a package main before its directory index (#3844) * fix(reference): select a package main before its directory index import-reference-source resolved a relative directory import such as require("./dep") from path candidates alone, so it reported dep/index.js as the internal, parsed dependency even when dep/package.json declared "main": "actual.js". Node loads actual.js. parseReferenceSourceEntries now reads each inventoried package.json once. When an internal specifier resolves through a directory index, a non-empty string main is loaded first, as a file and then as a directory index, as Node does. A main whose target is absent falls back to the index, matching Node's legacy fallback. An unreadable or invalid manifest leaves the relationship unresolved instead of asserting the index. Fixes #3383 Co-Authored-By: Claude Opus 5.5 * fix(reference): resolve package main the way path.resolve does A trailing separator in main ("lib/") kept directory intent, so the declared entry selected lib/index.js where Node loads lib.js. An absolute main was joined under the package directory instead of leaving the imported root. Strip trailing separators after joining, and report an absolute main as unresolved. Both cases join the Node-oracle CLI test. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../ReferenceSourceImportEntries.ts | 115 ++++++++++++++++-- .../referenceSourceDirectoryImports.test.ts | 29 ++++- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index c53df6a9d1..fbea6e2e25 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -94,10 +94,95 @@ const failedEntry = ( }; }; +/** A package directory's legacy `main` entry point, as its manifest declares it. */ +type PackageMain = + | { readonly state: "declared"; readonly main: string } + | { readonly state: "none" } + | { readonly state: "unknown" }; + +/** + * Read each inventoried `package.json` once. Node selects a non-empty string + * `main` before a directory index; an unreadable or invalid manifest leaves + * that selection unknown. + */ +const readPackageMains = ( + read: ReferenceSourceRead, +): ReadonlyMap => { + const mains = new Map(); + for (const entry of read.entries) { + if (posix.basename(entry.path) !== "package.json") continue; + const directory = posix.dirname(entry.path); + if (entry.status !== "read" || entry.kind !== "file") { + mains.set(directory, { state: "unknown" }); + continue; + } + try { + const manifest: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(entry.bytes), + ); + const main = + typeof manifest === "object" && manifest !== null + ? Reflect.get(manifest, "main") + : undefined; + mains.set( + directory, + typeof main === "string" && main !== "" + ? { state: "declared", main } + : { state: "none" }, + ); + } catch { + mains.set(directory, { state: "unknown" }); + } + } + return mains; +}; + +const RESOLUTION_SUFFIXES = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; + +/** Inventory facts an internal specifier resolves against. */ +interface SpecifierContext { + readonly lookupPath: ReferenceSourcePathLookup; + readonly packageMains: ReadonlyMap; +} + +/** + * Apply a package `main` to a directory resolved through its index. Node loads + * the declared entry as a file, then as a directory index, and falls back to + * the package's own index only when neither exists. + */ +const selectPackageMain = ( + index: string, + context: SpecifierContext, +): { to: string; resolution: "internal" | "unresolved" } => { + const directory = posix.dirname(index); + const manifest = context.packageMains.get(directory); + if (manifest === undefined || manifest.state === "none") + return { to: index, resolution: "internal" }; + if (manifest.state === "unknown") + return { to: directory, resolution: "unresolved" }; + // Node resolves `main` with path.resolve: an absolute main leaves the + // package, and a trailing separator does not make the entry a directory. + if (posix.isAbsolute(manifest.main)) + return { to: posix.normalize(manifest.main), resolution: "unresolved" }; + const target = posix + .normalize(posix.join(directory, manifest.main)) + .replace(/(?<=.)\/+$/u, ""); + // Node would load a declared entry outside the imported root. + if (logicalPathEscapesRoot(target)) + return { to: target, resolution: "unresolved" }; + const selected = context.lookupPath.resolveCandidates([ + ...RESOLUTION_SUFFIXES.map((suffix) => `${target}${suffix}`), + ...RESOLUTION_SUFFIXES.filter((suffix) => suffix !== "").map((suffix) => + posix.join(target, `index${suffix}`), + ), + ]); + return { to: selected ?? index, resolution: "internal" }; +}; + const resolveInternalSpecifier = ( fromPath: string, specifier: string, - lookupPath: ReferenceSourcePathLookup, + context: SpecifierContext, ): { to: string; resolution: "internal" | "unresolved" } => { if (posix.isAbsolute(specifier)) return { to: specifier, resolution: "unresolved" }; @@ -109,10 +194,9 @@ const resolveInternalSpecifier = ( // Preserve directory intent before normalization removes terminal dot segments. const directoryReference = specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier)); - const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; // A directory resolves only to an index with an extension; neither Node nor // TypeScript selects an extensionless `index` file. - const candidates = suffixes.flatMap((suffix) => [ + const candidates = RESOLUTION_SUFFIXES.flatMap((suffix) => [ ...(directoryReference ? [] : [`${normalized}${suffix}`]), ...(suffix === "" ? [] : [posix.join(normalized, `index${suffix}`)]), ]); @@ -124,9 +208,13 @@ const resolveInternalSpecifier = ( ({ path }) => path, ), ); - const match = lookupPath.resolveCandidates(candidates); - return match === undefined - ? { to: normalized, resolution: "unresolved" } + const match = context.lookupPath.resolveCandidates(candidates); + if (match === undefined) return { to: normalized, resolution: "unresolved" }; + const resolvedThroughIndex = + posix.basename(match).startsWith("index.") && + posix.dirname(match).normalize("NFC") === normalized.normalize("NFC"); + return resolvedThroughIndex + ? selectPackageMain(match, context) : { to: match, resolution: "internal" }; }; @@ -134,7 +222,7 @@ type ReadFile = Extract; const appendFile = ( entry: ReadFile, - lookupPath: ReferenceSourcePathLookup, + context: SpecifierContext, output: ParsedReferenceSourceEntries, ): void => { const language = detectReferenceSourceLanguage(entry.path); @@ -161,7 +249,7 @@ const appendFile = ( const resolved = resolveInternalSpecifier( relationship.from_path, relationship.to, - lookupPath, + context, ); output.relationships.push({ ...relationship, @@ -174,11 +262,11 @@ const appendFile = ( const appendReadEntry = ( entry: Extract, - lookupPath: ReferenceSourcePathLookup, + context: SpecifierContext, output: ParsedReferenceSourceEntries, ): void => { if (entry.kind === "file") { - appendFile(entry, lookupPath, output); + appendFile(entry, context, output); return; } const classifications = classifyReferenceSourcePath(entry.path); @@ -221,11 +309,14 @@ export const parseReferenceSourceEntries = ( parseFailures: [], limitations: [...read.limitations], }; - const lookupPath = createReferenceSourcePathLookup(filePaths); + const context: SpecifierContext = { + lookupPath: createReferenceSourcePathLookup(filePaths), + packageMains: readPackageMains(read), + }; for (const entry of read.entries) { if (signal?.aborted === true) break; if (entry.status === "failed") output.entries.push(failedEntry(entry)); - else appendReadEntry(entry, lookupPath, output); + else appendReadEntry(entry, context, output); } return output; }; diff --git a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts index 8fbd8a421c..0d2f72421c 100644 --- a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts +++ b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts @@ -19,6 +19,7 @@ describe.skipIf(process.platform === "win32")( "rea-reference-directory-imports-", ); const root = join(workspace, "source"); + const outside = join(workspace, "outside", "index.js"); const imports = [ ["slash.cjs", "./lib/", "lib/index.js"], ["dot.cjs", "./lib/.", "lib/index.js"], @@ -30,12 +31,17 @@ describe.skipIf(process.platform === "win32")( ["fallback.cjs", "./fallback", "fallback/index.js"], ["legacy-order.cjs", "./dep", "dep/index.js"], ["bare-index.cjs", "./bare", "bare.js"], + ["package-main.cjs", "./pkg", "pkg/actual.js"], + ["package-main-fallback.cjs", "./stale", "stale/index.js"], + ["package-main-slash.cjs", "./slashed", "slashed/lib.js"], ] as const; const unresolved = [ ["missing.cjs", "./missing/", "missing/"], ["escape.cjs", "../outside/", "../outside/"], ["main.ts", "./folder.js/.", "folder.js"], ["only-bare.cjs", "./only/", "only/"], + ["invalid-manifest.cjs", "./broken", "broken"], + ["absolute-main.cjs", "./absolute", outside], ] as const; const files = new Map([ ["index.js", 'module.exports = "root index";\n'], @@ -53,6 +59,22 @@ describe.skipIf(process.platform === "win32")( ["bare.js", 'module.exports = "file before directory";\n'], ["bare/index", 'module.exports = "extensionless index";\n'], ["only/index", 'module.exports = "extensionless index";\n'], + // A package main outranks the index; a missing main falls back to it. + ["pkg/package.json", '{"main":"actual.js"}\n'], + ["pkg/actual.js", 'module.exports = "declared main";\n'], + ["pkg/index.js", 'module.exports = "ignored index";\n'], + ["stale/package.json", '{"main":"gone.js"}\n'], + ["stale/index.js", 'module.exports = "legacy fallback";\n'], + ["broken/package.json", '{"main":\n'], + ["broken/index.js", 'module.exports = "unknown selection";\n'], + // Node resolves main with path.resolve: no trailing-slash directory + // intent, and an absolute main leaves the imported root. + ["slashed/package.json", '{"main":"lib/"}\n'], + ["slashed/lib.js", 'module.exports = "file main";\n'], + ["slashed/lib/index.js", 'module.exports = "not selected";\n'], + ["slashed/index.js", 'module.exports = "not selected";\n'], + ["absolute/package.json", `${JSON.stringify({ main: outside })}\n`], + ["absolute/index.js", 'module.exports = "not selected";\n'], ...[...imports, ...unresolved].map( ([path, specifier]) => [path, `require(${JSON.stringify(specifier)});\n`] as const, @@ -65,7 +87,6 @@ describe.skipIf(process.platform === "win32")( await writeFile(target, contents); }), ); - const outside = join(workspace, "outside", "index.js"); await mkdir(dirname(outside), { recursive: true }); await writeFile(outside, 'module.exports = "outside import root";\n'); @@ -83,9 +104,15 @@ describe.skipIf(process.platform === "win32")( expect(() => createRequire(join(root, "only-bare.cjs")).resolve("./only/"), ).toThrow(); + expect(() => + createRequire(join(root, "invalid-manifest.cjs")).resolve("./broken"), + ).toThrow(); expect( createRequire(join(root, "escape.cjs")).resolve("../outside/"), ).toBe(outside); + expect( + createRequire(join(root, "absolute-main.cjs")).resolve("./absolute"), + ).toBe(outside); const result = await cli.run({ arguments: ["import-reference-source", root, "--json"], From a1ca8c76af64000e119fd2a14f34cbf28869f5d8 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:18:54 +0800 Subject: [PATCH 18/21] fix(website): give navigation room and refresh contributors (#3851) Give the header its own 1320px container while preserving body/article widths, move the complete navigation below the controls on smaller screens, and refresh the dated public contributor snapshot from 214 to 216 accounts. Validated shared HTML/translations/links/assets and 16 maintenance tests; local Chromium acceptance covered 20 languages, 15 viewport widths and both root/project paths. CI and Greptile review passed. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- website/locales/ar/messages.po | 20 ++-- website/locales/de/messages.po | 20 ++-- website/locales/el/messages.po | 20 ++-- website/locales/es/messages.po | 20 ++-- website/locales/fa/messages.po | 20 ++-- website/locales/fr/messages.po | 20 ++-- website/locales/id/messages.po | 20 ++-- website/locales/it/messages.po | 20 ++-- website/locales/ja/messages.po | 20 ++-- website/locales/ko/messages.po | 20 ++-- website/locales/messages.pot | 20 ++-- website/locales/pl/messages.po | 20 ++-- website/locales/pt-BR/messages.po | 20 ++-- website/locales/ru/messages.po | 20 ++-- website/locales/th/messages.po | 20 ++-- website/locales/tr/messages.po | 20 ++-- website/locales/uk/messages.po | 20 ++-- website/locales/vi/messages.po | 20 ++-- website/locales/zh-CN/messages.po | 20 ++-- website/locales/zh-TW/messages.po | 20 ++-- website/public/assets/styles.css | 41 +++----- website/public/contributors/index.html | 10 +- website/public/contributors/people.json | 122 +++++++++++++----------- website/style-guide.md | 7 +- 24 files changed, 293 insertions(+), 287 deletions(-) diff --git a/website/locales/ar/messages.po b/website/locales/ar/messages.po index d448d481bf..ef6adb0b9a 100644 --- a/website/locales/ar/messages.po +++ b/website/locales/ar/messages.po @@ -67,7 +67,7 @@ msgstr "بواسطة N0zoM1z0 * 10 أكتوبر 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10670,7 +10670,7 @@ msgstr "الأدلة" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10692,7 +10692,7 @@ msgstr "الكود المصدري" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10714,7 +10714,7 @@ msgstr "رخصة MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10736,7 +10736,7 @@ msgstr "انضم إلى المجتمع" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -14734,7 +14734,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -14818,14 +14818,14 @@ msgstr "البحث عن مساهم" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "من {ph0}قائمة المساهمين على GitHub{ph1} · آخر تحديث: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "لا يوجد مساهمون مطابقون. جرّب اسم مستخدم آخر." @@ -14846,7 +14846,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "اسم المستخدم على GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "العودة للأعلى" diff --git a/website/locales/de/messages.po b/website/locales/de/messages.po index 37e2df5e4f..6729fa9d5c 100644 --- a/website/locales/de/messages.po +++ b/website/locales/de/messages.po @@ -68,7 +68,7 @@ msgstr "Von N0zoM1z0 · 10. Oktober 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -11092,7 +11092,7 @@ msgstr "Anleitungen" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -11114,7 +11114,7 @@ msgstr "Quellcode" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -11136,7 +11136,7 @@ msgstr "MIT-Lizenz" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -11158,7 +11158,7 @@ msgstr "Zur Community" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15358,7 +15358,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15443,15 +15443,15 @@ msgstr "Mitwirkende finden" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "" "Aus der {ph0}Mitwirkendenliste auf GitHub{ph1} · Aktualisiert am {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "" @@ -15475,7 +15475,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "GitHub-Benutzername" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Nach oben" diff --git a/website/locales/el/messages.po b/website/locales/el/messages.po index 365ab73ba6..60736991f2 100644 --- a/website/locales/el/messages.po +++ b/website/locales/el/messages.po @@ -180,7 +180,7 @@ msgstr "Του N0zoM1z0 · 10 Οκτωβρίου 2026" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -202,7 +202,7 @@ msgstr "Πηγαίος κώδικας" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -224,7 +224,7 @@ msgstr "Άδεια MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -246,7 +246,7 @@ msgstr "Ελάτε στην κοινότητα" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -270,7 +270,7 @@ msgstr "Ακολουθήστε μας στο X" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -15312,7 +15312,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15397,14 +15397,14 @@ msgstr "Βρείτε έναν συνεισφέροντα" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "Από τη {ph0}λίστα συνεισφερόντων του GitHub{ph1} · Ενημερώθηκε {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "" @@ -15428,7 +15428,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Όνομα χρήστη GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Επιστροφή στην κορυφή" diff --git a/website/locales/es/messages.po b/website/locales/es/messages.po index 6ec8bd15df..8ab8dbdc59 100644 --- a/website/locales/es/messages.po +++ b/website/locales/es/messages.po @@ -68,7 +68,7 @@ msgstr "Por N0zoM1z0 * 10 octubre 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -11018,7 +11018,7 @@ msgstr "Guías" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -11040,7 +11040,7 @@ msgstr "Código fuente" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -11062,7 +11062,7 @@ msgstr "Licencia MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -11084,7 +11084,7 @@ msgstr "Únete a la comunidad" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15241,7 +15241,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15326,15 +15326,15 @@ msgstr "Buscar colaboradores" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "" "De la {ph0}lista de colaboradores de GitHub{ph1} · Actualizada el {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "No se encontraron colaboradores. Prueba con otro nombre de usuario." @@ -15356,7 +15356,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Nombre de usuario de GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Volver arriba" diff --git a/website/locales/fa/messages.po b/website/locales/fa/messages.po index 0fb8478ccb..0aef33a0c5 100644 --- a/website/locales/fa/messages.po +++ b/website/locales/fa/messages.po @@ -67,7 +67,7 @@ msgstr "توسط N0zoM1z0 * 10 اکتبر 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10762,7 +10762,7 @@ msgstr "راهنماها" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10784,7 +10784,7 @@ msgstr "کد منبع" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10806,7 +10806,7 @@ msgstr "مجوز MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10828,7 +10828,7 @@ msgstr "به جامعه بپیوندید" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -14888,7 +14888,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -14973,14 +14973,14 @@ msgstr "جستجوی مشارکت‌کننده" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "از {ph0}فهرست مشارکت‌کنندگان GitHub{ph1} · به‌روزرسانی: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "مشارکت‌کننده‌ای پیدا نشد. نام کاربری دیگری را امتحان کنید." @@ -15003,7 +15003,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "نام کاربری GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "بازگشت به بالا" diff --git a/website/locales/fr/messages.po b/website/locales/fr/messages.po index 37a0003b81..dde3191cb5 100644 --- a/website/locales/fr/messages.po +++ b/website/locales/fr/messages.po @@ -68,7 +68,7 @@ msgstr "Par N0zoM1z0 · 10 octobre 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -11027,7 +11027,7 @@ msgstr "Guides" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -11049,7 +11049,7 @@ msgstr "Code source" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -11071,7 +11071,7 @@ msgstr "Licence MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -11093,7 +11093,7 @@ msgstr "Rejoindre la communauté" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15289,7 +15289,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15374,8 +15374,8 @@ msgstr "Rechercher un contributeur" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." @@ -15383,7 +15383,7 @@ msgstr "" "D’après la {ph0}liste des contributeurs sur GitHub{ph1} · Mise à jour le " "{ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Aucun contributeur trouvé. Essayez un autre nom d’utilisateur." @@ -15406,7 +15406,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Nom d’utilisateur GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Haut de page" diff --git a/website/locales/id/messages.po b/website/locales/id/messages.po index 32919490e1..0776768eaf 100644 --- a/website/locales/id/messages.po +++ b/website/locales/id/messages.po @@ -68,7 +68,7 @@ msgstr "Oleh N0zoM1z0 · 10 Oktober 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10920,7 +10920,7 @@ msgstr "Panduan" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10942,7 +10942,7 @@ msgstr "Kode sumber" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10964,7 +10964,7 @@ msgstr "Lisensi MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10986,7 +10986,7 @@ msgstr "Gabung komunitas" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15122,7 +15122,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15207,14 +15207,14 @@ msgstr "Cari kontributor" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "Dari {ph0}daftar kontributor GitHub{ph1} · Diperbarui pada {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Tidak ada kontributor yang cocok. Coba nama pengguna lain." @@ -15237,7 +15237,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Nama pengguna GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Kembali ke atas" diff --git a/website/locales/it/messages.po b/website/locales/it/messages.po index c2c13137b2..1146d4d923 100644 --- a/website/locales/it/messages.po +++ b/website/locales/it/messages.po @@ -67,7 +67,7 @@ msgstr "Di N0zoM1z0 * 10 ottobre 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10970,7 +10970,7 @@ msgstr "Guide" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10992,7 +10992,7 @@ msgstr "Codice sorgente" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -11014,7 +11014,7 @@ msgstr "Licenza MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -11036,7 +11036,7 @@ msgstr "Unisciti alla community" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15186,7 +15186,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15271,15 +15271,15 @@ msgstr "Trova un collaboratore" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "" "Dall’{ph0}elenco dei collaboratori su GitHub{ph1} · Aggiornato il {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Nessun collaboratore trovato. Prova un altro nome utente." @@ -15301,7 +15301,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Nome utente GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Torna in alto" diff --git a/website/locales/ja/messages.po b/website/locales/ja/messages.po index 778f7b5e6e..37d3cc5448 100644 --- a/website/locales/ja/messages.po +++ b/website/locales/ja/messages.po @@ -66,7 +66,7 @@ msgstr "によってN0zoM1z0•10 10月2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10205,7 +10205,7 @@ msgstr "ガイド" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10227,7 +10227,7 @@ msgstr "ソースコード" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10249,7 +10249,7 @@ msgstr "MIT ライセンス" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10271,7 +10271,7 @@ msgstr "コミュニティに参加" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -14020,7 +14020,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -14103,14 +14103,14 @@ msgstr "貢献者を探す" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "{ph0}GitHub の貢献者一覧{ph1}より · 更新日:{ph2}。" -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "該当する貢献者が見つかりません。別のユーザー名で検索してください。" @@ -14131,7 +14131,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "GitHub ユーザー名" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "ページ上部へ" diff --git a/website/locales/ko/messages.po b/website/locales/ko/messages.po index 529f2bfded..83b85f3431 100644 --- a/website/locales/ko/messages.po +++ b/website/locales/ko/messages.po @@ -65,7 +65,7 @@ msgstr "여N0zoM1z0·10 월 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10181,7 +10181,7 @@ msgstr "가이드" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10203,7 +10203,7 @@ msgstr "소스 코드" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10225,7 +10225,7 @@ msgstr "MIT 라이선스" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10247,7 +10247,7 @@ msgstr "커뮤니티 참여" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -14058,7 +14058,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -14141,14 +14141,14 @@ msgstr "기여자 찾기" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "{ph0}GitHub 기여자 목록{ph1} 기준 · 업데이트: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "일치하는 기여자가 없습니다. 다른 사용자 이름으로 검색해 보세요." @@ -14169,7 +14169,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "GitHub 사용자 이름" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "맨 위로" diff --git a/website/locales/messages.pot b/website/locales/messages.pot index 2e2ea3a6c5..c7d4d1e591 100644 --- a/website/locales/messages.pot +++ b/website/locales/messages.pot @@ -93,7 +93,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 website/public/faq/index.html:120 #: website/public/faq/index.html:568 website/public/first-investigation/index.html:120 @@ -192,7 +192,7 @@ msgstr "" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 #: website/public/get-started/index.html:1813 @@ -211,7 +211,7 @@ msgstr "" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 #: website/public/get-started/index.html:1814 @@ -230,7 +230,7 @@ msgstr "" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 #: website/public/get-started/index.html:1815 @@ -249,7 +249,7 @@ msgstr "" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 #: website/public/get-started/index.html:1816 @@ -270,7 +270,7 @@ msgstr "" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 #: website/public/get-started/index.html:1821 @@ -2701,14 +2701,14 @@ msgstr "" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "" -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "" @@ -2728,7 +2728,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "" diff --git a/website/locales/pl/messages.po b/website/locales/pl/messages.po index 2c88e4ab6c..e170e175cb 100644 --- a/website/locales/pl/messages.po +++ b/website/locales/pl/messages.po @@ -67,7 +67,7 @@ msgstr "Przez N0zoM1z0 * 10 października 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10880,7 +10880,7 @@ msgstr "Poradniki" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10902,7 +10902,7 @@ msgstr "Kod źródłowy" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10924,7 +10924,7 @@ msgstr "Licencja MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10946,7 +10946,7 @@ msgstr "Dołącz do społeczności" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15077,7 +15077,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15162,15 +15162,15 @@ msgstr "Znajdź współtwórcę" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "" "Na podstawie {ph0}listy współtwórców na GitHubie{ph1} · Aktualizacja: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "" @@ -15192,7 +15192,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Nazwa użytkownika GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Do góry" diff --git a/website/locales/pt-BR/messages.po b/website/locales/pt-BR/messages.po index 1045b06cc2..01aeeaa3dc 100644 --- a/website/locales/pt-BR/messages.po +++ b/website/locales/pt-BR/messages.po @@ -67,7 +67,7 @@ msgstr "Por N0zoM1z0 * 10 de outubro de 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10956,7 +10956,7 @@ msgstr "Guias" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10978,7 +10978,7 @@ msgstr "Código-fonte" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -11000,7 +11000,7 @@ msgstr "Licença MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -11022,7 +11022,7 @@ msgstr "Participe da comunidade" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15158,7 +15158,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15243,14 +15243,14 @@ msgstr "Encontrar colaboradores" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "Da {ph0}lista de colaboradores do GitHub{ph1} · Atualizada em {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Nenhum colaborador encontrado. Tente outro nome de usuário." @@ -15272,7 +15272,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Nome de usuário do GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Voltar ao topo" diff --git a/website/locales/ru/messages.po b/website/locales/ru/messages.po index a82d372fb3..41b74f4c0d 100644 --- a/website/locales/ru/messages.po +++ b/website/locales/ru/messages.po @@ -67,7 +67,7 @@ msgstr "Автор: N0zoM1z0 · 10 октября 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10988,7 +10988,7 @@ msgstr "Руководства" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -11010,7 +11010,7 @@ msgstr "Исходный код" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -11032,7 +11032,7 @@ msgstr "Лицензия MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -11054,7 +11054,7 @@ msgstr "Вступить в сообщество" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15191,7 +15191,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15275,14 +15275,14 @@ msgstr "Найти участника" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "По {ph0}списку участников на GitHub{ph1} · Обновлено: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Участники не найдены. Попробуйте другое имя пользователя." @@ -15305,7 +15305,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Имя пользователя на GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Наверх" diff --git a/website/locales/th/messages.po b/website/locales/th/messages.po index 7aabf926d5..aa08236c54 100644 --- a/website/locales/th/messages.po +++ b/website/locales/th/messages.po @@ -67,7 +67,7 @@ msgstr "โดย N0zoM1z0 ·10 ตุลาคม 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10661,7 +10661,7 @@ msgstr "คู่มือ" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10683,7 +10683,7 @@ msgstr "ซอร์สโค้ด" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10705,7 +10705,7 @@ msgstr "สัญญาอนุญาต MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10727,7 +10727,7 @@ msgstr "เข้าร่วมชุมชน" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -14789,7 +14789,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -14872,14 +14872,14 @@ msgstr "ค้นหาผู้มีส่วนร่วม" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "จาก {ph0}รายชื่อผู้มีส่วนร่วมบน GitHub{ph1} · อัปเดตเมื่อ {ph2}" -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "ไม่พบผู้มีส่วนร่วมที่ตรงกัน ลองใช้ชื่อผู้ใช้อื่น" @@ -14900,7 +14900,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "ชื่อผู้ใช้ GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "กลับขึ้นด้านบน" diff --git a/website/locales/tr/messages.po b/website/locales/tr/messages.po index e6dbfe5ac9..914e760ba5 100644 --- a/website/locales/tr/messages.po +++ b/website/locales/tr/messages.po @@ -68,7 +68,7 @@ msgstr "Başlangıç N0zoM1z0 * 10 Ekim 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10870,7 +10870,7 @@ msgstr "Rehberler" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10892,7 +10892,7 @@ msgstr "Kaynak kod" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10914,7 +10914,7 @@ msgstr "MIT lisansı" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10936,7 +10936,7 @@ msgstr "Topluluğa katıl" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15056,7 +15056,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15141,14 +15141,14 @@ msgstr "Katkıda bulunan birini bul" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "{ph0}GitHub katkıda bulunanlar listesi{ph1} · Güncelleme: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Eşleşen katkıda bulunan kişi yok. Başka bir kullanıcı adı deneyin." @@ -15169,7 +15169,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "GitHub kullanıcı adı" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Başa dön" diff --git a/website/locales/uk/messages.po b/website/locales/uk/messages.po index 569051dbec..ece5d6ef31 100644 --- a/website/locales/uk/messages.po +++ b/website/locales/uk/messages.po @@ -67,7 +67,7 @@ msgstr "Автор: N0zoM1z0 · 10 жовтня 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10945,7 +10945,7 @@ msgstr "Посібники" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10967,7 +10967,7 @@ msgstr "Вихідний код" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10989,7 +10989,7 @@ msgstr "Ліцензія MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -11011,7 +11011,7 @@ msgstr "Приєднатися до спільноти" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15136,7 +15136,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15220,14 +15220,14 @@ msgstr "Знайти учасника" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "За {ph0}списком учасників на GitHub{ph1} · Оновлено: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Учасників не знайдено. Спробуйте інше ім’я користувача." @@ -15250,7 +15250,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Ім’я користувача на GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Нагору" diff --git a/website/locales/vi/messages.po b/website/locales/vi/messages.po index 091f1f2ed4..2e2cc58e82 100644 --- a/website/locales/vi/messages.po +++ b/website/locales/vi/messages.po @@ -67,7 +67,7 @@ msgstr "Bởi N0zoM1z0 * 10 tháng mười 2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -10861,7 +10861,7 @@ msgstr "Hướng dẫn" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -10883,7 +10883,7 @@ msgstr "Mã nguồn" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10905,7 +10905,7 @@ msgstr "Giấy phép MIT" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10927,7 +10927,7 @@ msgstr "Tham gia cộng đồng" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -15026,7 +15026,7 @@ msgstr "" #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -15111,14 +15111,14 @@ msgstr "Tìm người đóng góp" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "Từ {ph0}danh sách người đóng góp trên GitHub{ph1} · Cập nhật: {ph2}." -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "Không tìm thấy người đóng góp phù hợp. Hãy thử tên người dùng khác." @@ -15140,7 +15140,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "Tên người dùng GitHub" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "Về đầu trang" diff --git a/website/locales/zh-CN/messages.po b/website/locales/zh-CN/messages.po index 25e946a2a1..16617ccdf6 100644 --- a/website/locales/zh-CN/messages.po +++ b/website/locales/zh-CN/messages.po @@ -65,7 +65,7 @@ msgstr "通过N0zoM1z0*10十月2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -9958,7 +9958,7 @@ msgstr "指南" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -9980,7 +9980,7 @@ msgstr "源代码" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10002,7 +10002,7 @@ msgstr "MIT 许可证" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10024,7 +10024,7 @@ msgstr "加入社区" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -13705,7 +13705,7 @@ msgstr "使用 REA 和我已配置的分析工具检查 {ph0}。列出其中的 #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -13788,14 +13788,14 @@ msgstr "查找贡献者" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "来自 {ph0}GitHub 贡献者列表{ph1} · 更新于 {ph2}。" -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "没有找到匹配的贡献者。试试其他用户名。" @@ -13816,7 +13816,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "GitHub 用户名" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "回到顶部" diff --git a/website/locales/zh-TW/messages.po b/website/locales/zh-TW/messages.po index 3ba39c812b..384f5d1623 100644 --- a/website/locales/zh-TW/messages.po +++ b/website/locales/zh-TW/messages.po @@ -65,7 +65,7 @@ msgstr "透過N0zoM1z0*10十月2026" #: website/public/blog/index.html:248 #: website/public/blog/reverse-engineering-models/index.html:887 #: website/public/blog/touhou-reconstruction/index.html:1084 -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 #: website/public/examples/dino-lab/index.html:479 #: website/public/faq/index.html:577 #: website/public/first-investigation/index.html:538 @@ -9974,7 +9974,7 @@ msgstr "指南" #: website/public/blog/index.html:240 #: website/public/blog/reverse-engineering-models/index.html:879 #: website/public/blog/touhou-reconstruction/index.html:1076 -#: website/public/contributors/index.html:1119 +#: website/public/contributors/index.html:1127 #: website/public/examples/dino-lab/index.html:471 #: website/public/faq/index.html:569 #: website/public/first-investigation/index.html:530 @@ -9996,7 +9996,7 @@ msgstr "原始碼" #: website/public/blog/index.html:241 #: website/public/blog/reverse-engineering-models/index.html:880 #: website/public/blog/touhou-reconstruction/index.html:1077 -#: website/public/contributors/index.html:1120 +#: website/public/contributors/index.html:1128 #: website/public/examples/dino-lab/index.html:472 #: website/public/faq/index.html:570 #: website/public/first-investigation/index.html:531 @@ -10018,7 +10018,7 @@ msgstr "MIT 許可證" #: website/public/blog/index.html:242 #: website/public/blog/reverse-engineering-models/index.html:881 #: website/public/blog/touhou-reconstruction/index.html:1078 -#: website/public/contributors/index.html:1121 +#: website/public/contributors/index.html:1129 #: website/public/examples/dino-lab/index.html:473 #: website/public/faq/index.html:571 #: website/public/first-investigation/index.html:532 @@ -10040,7 +10040,7 @@ msgstr "加入社群" #: website/public/blog/index.html:243 #: website/public/blog/reverse-engineering-models/index.html:882 #: website/public/blog/touhou-reconstruction/index.html:1079 -#: website/public/contributors/index.html:1122 +#: website/public/contributors/index.html:1130 #: website/public/examples/dino-lab/index.html:474 #: website/public/faq/index.html:572 #: website/public/first-investigation/index.html:533 @@ -13723,7 +13723,7 @@ msgstr "使用 REA 和我已設定的分析工具檢查 {ph0}。列出其中的 #: website/public/blog/touhou-reconstruction/index.html:120 #: website/public/blog/touhou-reconstruction/index.html:1075 #: website/public/contributors/index.html:121 -#: website/public/contributors/index.html:1118 +#: website/public/contributors/index.html:1126 #: website/public/examples/dino-lab/index.html:121 #: website/public/examples/dino-lab/index.html:470 #: website/public/faq/index.html:120 website/public/faq/index.html:568 @@ -13806,14 +13806,14 @@ msgstr "尋找貢獻者" #. {ph0}: #. {ph1}: -#. {ph2}: -#: website/public/contributors/index.html:1102 +#. {ph2}: +#: website/public/contributors/index.html:1110 #, python-brace-format msgctxt "page:contributors/index.html" msgid "From {ph0}GitHub’s contributor list{ph1} · Updated {ph2}." msgstr "來自 {ph0}GitHub 貢獻者名單{ph1} · 更新於 {ph2}。" -#: website/public/contributors/index.html:1107 +#: website/public/contributors/index.html:1115 msgctxt "page:contributors/index.html" msgid "No matching contributors. Try another username." msgstr "找不到符合的貢獻者。試試其他使用者名稱。" @@ -13834,7 +13834,7 @@ msgctxt "page:contributors/index.html" msgid "GitHub username" msgstr "GitHub 使用者名稱" -#: website/public/contributors/index.html:1127 +#: website/public/contributors/index.html:1135 msgctxt "page:contributors/index.html" msgid "Back to top" msgstr "回到頂部" diff --git a/website/public/assets/styles.css b/website/public/assets/styles.css index ebfb2b60e9..6644a461e7 100644 --- a/website/public/assets/styles.css +++ b/website/public/assets/styles.css @@ -228,11 +228,12 @@ section[id] { font-size: 0.9rem; } .header-inner { + width: min(1320px, calc(100% - 3.5rem)); min-height: 88px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 2rem; + gap: 1.5rem; } .brand { display: flex; @@ -2541,9 +2542,6 @@ pre code { .home-reassurance { max-width: 600px; } - .brand-caption { - display: none; - } .feature-case { gap: 2.4rem; } @@ -2605,25 +2603,10 @@ pre code { .container { width: calc(100% - 2.5rem); } - .header-inner { - min-height: 78px; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.55rem; - padding-block: 0.8rem; - } .navigation { - grid-row: 2; - grid-column: 1 / -1; - width: 100%; - justify-content: space-between; - flex-wrap: wrap; - gap: 0.5rem 0.25rem; + gap: 0.5rem 0.8rem; font-size: 0.875rem; } - .header-social { - grid-row: 1; - grid-column: 2; - } .hero { padding-block: 3.5rem 3rem; } @@ -2895,23 +2878,21 @@ html[dir="rtl"] .breadcrumb, html[dir="rtl"] .brand-name { unicode-bidi: isolate; } -@media (min-width: 851px) and (max-width: 1250px) { +@media (max-width: 1279px) { .brand-caption { display: none; } } -@media (max-width: 850px) { +@media (max-width: 1100px) { .header-inner { grid-template-columns: auto minmax(0, 1fr); - } - .brand-caption { - display: none; + row-gap: 0.65rem; + padding-block: 0.8rem; } .navigation { grid-column: 1 / -1; grid-row: 2; justify-content: flex-start; - padding-bottom: 0.8rem; } .header-social { grid-column: 2; @@ -2920,9 +2901,6 @@ html[dir="rtl"] .brand-name { justify-content: flex-end; gap: 0.45rem; } - .nav-github-count { - display: none; - } .language-picker { position: static; } @@ -2931,6 +2909,11 @@ html[dir="rtl"] .brand-name { top: 4.3rem; width: auto; } +} +@media (max-width: 850px) { + .nav-github-count { + display: none; + } .explanatory-figure figcaption a { white-space: normal; } diff --git a/website/public/contributors/index.html b/website/public/contributors/index.html index c4a2e98c64..9f6072d0bb 100644 --- a/website/public/contributors/index.html +++ b/website/public/contributors/index.html @@ -650,6 +650,10 @@ @JingHao-Leon +
  • + + @JNTMTMTM +
  • @JonahSimon @@ -950,6 +954,10 @@ @suninrain086
  • +
  • + + @Swatantra-66 +
  • @sx4im @@ -1100,7 +1108,7 @@
  • - From GitHub’s contributor list · Updated . + From GitHub’s contributor list · Updated .

    diff --git a/website/public/contributors/people.json b/website/public/contributors/people.json index 525cabc6fc..001b81b5ad 100644 --- a/website/public/contributors/people.json +++ b/website/public/contributors/people.json @@ -1,6 +1,6 @@ { "repository": "morluto/rea", - "updated": "2026-10-14", + "updated": "2026-10-15", "contributors": [ { "avatar_url": "https://avatars.githubusercontent.com/u/76467478?v=4", @@ -37,13 +37,6 @@ "login": "github-actions[bot]", "type": "Bot" }, - { - "avatar_url": "https://avatars.githubusercontent.com/u/128062316?v=4", - "html_url": "https://github.com/sx4im", - "id": 128062316, - "login": "sx4im", - "type": "User" - }, { "avatar_url": "https://avatars.githubusercontent.com/u/114888155?v=4", "html_url": "https://github.com/DeryFerd", @@ -51,6 +44,13 @@ "login": "DeryFerd", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/128062316?v=4", + "html_url": "https://github.com/sx4im", + "id": 128062316, + "login": "sx4im", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/29498139?v=4", "html_url": "https://github.com/Y0UW1SH", @@ -149,6 +149,13 @@ "login": "pablodiazjorge", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/140487034?v=4", + "html_url": "https://github.com/JayOfTheKeyboard", + "id": 140487034, + "login": "JayOfTheKeyboard", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/223882680?v=4", "html_url": "https://github.com/yasshmittal", @@ -205,13 +212,6 @@ "login": "huiq777", "type": "User" }, - { - "avatar_url": "https://avatars.githubusercontent.com/u/140487034?v=4", - "html_url": "https://github.com/JayOfTheKeyboard", - "id": 140487034, - "login": "JayOfTheKeyboard", - "type": "User" - }, { "avatar_url": "https://avatars.githubusercontent.com/u/193022578?v=4", "html_url": "https://github.com/jayhemnani9910", @@ -275,6 +275,13 @@ "login": "1NormalGuy", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/131595336?v=4", + "html_url": "https://github.com/To3akaRin", + "id": 131595336, + "login": "To3akaRin", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/36695017?v=4", "html_url": "https://github.com/flitzrrr", @@ -345,13 +352,6 @@ "login": "TonyWu2333", "type": "User" }, - { - "avatar_url": "https://avatars.githubusercontent.com/u/131595336?v=4", - "html_url": "https://github.com/To3akaRin", - "id": 131595336, - "login": "To3akaRin", - "type": "User" - }, { "avatar_url": "https://avatars.githubusercontent.com/u/157806066?v=4", "html_url": "https://github.com/shubhamahuja9999", @@ -520,6 +520,13 @@ "login": "jimpablo", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/330186599?v=4", + "html_url": "https://github.com/qorvr", + "id": 330186599, + "login": "qorvr", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/101505019?v=4", "html_url": "https://github.com/vilmerfrost", @@ -597,6 +604,13 @@ "login": "claude", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/169631528?v=4", + "html_url": "https://github.com/Adkid-Zephyr", + "id": 169631528, + "login": "Adkid-Zephyr", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/8454518?v=4", "html_url": "https://github.com/jcamargos", @@ -625,6 +639,13 @@ "login": "JasonBuildAI", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/190670431?v=4", + "html_url": "https://github.com/8rulerstar", + "id": 190670431, + "login": "8rulerstar", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/163161978?v=4", "html_url": "https://github.com/dollarsmoney", @@ -653,20 +674,6 @@ "login": "Melon-tgj", "type": "User" }, - { - "avatar_url": "https://avatars.githubusercontent.com/u/169631528?v=4", - "html_url": "https://github.com/Adkid-Zephyr", - "id": 169631528, - "login": "Adkid-Zephyr", - "type": "User" - }, - { - "avatar_url": "https://avatars.githubusercontent.com/u/152221558?v=4", - "html_url": "https://github.com/ShivekRanjan", - "id": 152221558, - "login": "ShivekRanjan", - "type": "User" - }, { "avatar_url": "https://avatars.githubusercontent.com/u/136733716?v=4", "html_url": "https://github.com/ShreyanshJain105", @@ -688,6 +695,13 @@ "login": "Sunil56224972", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/112210438?v=4", + "html_url": "https://github.com/Swatantra-66", + "id": 112210438, + "login": "Swatantra-66", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/76486634?v=4", "html_url": "https://github.com/RishTheFish21", @@ -765,6 +779,13 @@ "login": "all-black-493", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/152221558?v=4", + "html_url": "https://github.com/ShivekRanjan", + "id": 152221558, + "login": "ShivekRanjan", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/217162076?v=4", "html_url": "https://github.com/shayanxcyber", @@ -864,10 +885,10 @@ "type": "User" }, { - "avatar_url": "https://avatars.githubusercontent.com/u/156576604?v=4", - "html_url": "https://github.com/Nik1313132", - "id": 156576604, - "login": "Nik1313132", + "avatar_url": "https://avatars.githubusercontent.com/u/186841349?v=4", + "html_url": "https://github.com/JNTMTMTM", + "id": 186841349, + "login": "JNTMTMTM", "type": "User" }, { @@ -961,13 +982,6 @@ "login": "saurabh-sol", "type": "User" }, - { - "avatar_url": "https://avatars.githubusercontent.com/u/330186599?v=4", - "html_url": "https://github.com/qorvr", - "id": 330186599, - "login": "qorvr", - "type": "User" - }, { "avatar_url": "https://avatars.githubusercontent.com/u/95270855?v=4", "html_url": "https://github.com/Zburgers", @@ -1290,6 +1304,13 @@ "login": "AICodeLion", "type": "User" }, + { + "avatar_url": "https://avatars.githubusercontent.com/u/156576604?v=4", + "html_url": "https://github.com/Nik1313132", + "id": 156576604, + "login": "Nik1313132", + "type": "User" + }, { "avatar_url": "https://avatars.githubusercontent.com/u/226919216?v=4", "html_url": "https://github.com/wunianze666-netizen", @@ -1374,13 +1395,6 @@ "login": "Jackmeson1", "type": "User" }, - { - "avatar_url": "https://avatars.githubusercontent.com/u/190670431?v=4", - "html_url": "https://github.com/8rulerstar", - "id": 190670431, - "login": "8rulerstar", - "type": "User" - }, { "avatar_url": "https://avatars.githubusercontent.com/u/1792039?v=4", "html_url": "https://github.com/zahhak", diff --git a/website/style-guide.md b/website/style-guide.md index 9e18e6cbfe..ec84f04f45 100644 --- a/website/style-guide.md +++ b/website/style-guide.md @@ -256,9 +256,10 @@ Embedded SVGs inherit the page's `color-scheme`; canvas demos listen for that differs from the system preference. Use the system sans-serif stack for prose and the shared monospace stack for -code. The current container is at most 1080px wide, with article text limited to -760px. Reuse the existing heading sizes, line heights, section spacing and -component classes. +code. The content container is at most 1080px wide, with article text limited to +760px. The header has its own 1320px container so navigation and utility controls +fit together. At 1100px and below, navigation moves to a separate row. Reuse the +existing heading sizes, line heights, section spacing and component classes. Whitespace separates ideas. Thin rules identify sections. Headings establish hierarchy. The blue accent identifies something useful. Keep these elements From cf0a38ad9774ee9acff3164052688f7a58a1c72c Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 22:22:59 -0700 Subject: [PATCH 19/21] fix(managed): project metadata with empty names without a display label (#3853) The managed application graph copied metadata names into observation labels. The graph schema requires a non-empty label or null, so Evidence whose Module name was the string heap's empty entry made project-managed-application-graph fail with invalid_request at observations.0.label. The inspector accepts that Evidence as complete, and no graph was returned. Labels derived from metadata names (assembly, module, type, member, P/Invoke import and native implementation) now become null when the name is empty. The node's properties keep the source value, so display formatting no longer rejects identity facts. Fixes #3352 Co-authored-by: Claude Opus 5.5 --- .../managed/managedApplicationGraphNodes.ts | 23 +++++++--- .../managedApplicationGraph.test.ts | 45 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/domain/managed/managedApplicationGraphNodes.ts b/src/domain/managed/managedApplicationGraphNodes.ts index 064b6f951e..117e8f6640 100644 --- a/src/domain/managed/managedApplicationGraphNodes.ts +++ b/src/domain/managed/managedApplicationGraphNodes.ts @@ -24,6 +24,13 @@ type PinvokeImport = ManagedNativeBoundaryInspection["pinvoke_imports"][number]; type NativeImplementation = ManagedNativeBoundaryInspection["native_implementations"][number]; +/** + * Display label for a metadata name. Metadata may hold an empty name; the + * graph then has no label, while the node's properties keep the source value. + */ +const metadataLabel = (name: string): string | null => + name === "" ? null : name; + /** Options for a managed contains relationship edge. */ export interface ContainsEdgeOptions { readonly kind: string; @@ -77,7 +84,7 @@ export const addArtifactIdentityNodes = ( ), observations: [ { - label: assemblyFacts.name, + label: metadataLabel(assemblyFacts.name), properties: { name: assemblyFacts.name, version: assemblyFacts.version, @@ -111,7 +118,7 @@ export const addArtifactIdentityNodes = ( ), observations: [ { - label: module.name, + label: metadataLabel(module.name), properties: { name: module.name, mvid: module.mvid, @@ -237,7 +244,7 @@ const typeNode = ( ), observations: [ { - label: type.full_name, + label: metadataLabel(type.full_name), properties: { token: type.token, namespace: type.namespace, @@ -266,10 +273,11 @@ const methodNode = ( ), observations: [ { - label: + label: metadataLabel( method.declaring_type === null ? method.name : `${method.declaring_type}.${method.name}`, + ), properties: { token: method.token, declaring_type_token: method.declaring_type_token, @@ -302,10 +310,11 @@ const fieldNode = ( ), observations: [ { - label: + label: metadataLabel( field.declaring_type === null ? field.name : `${field.declaring_type}.${field.name}`, + ), properties: { token: field.token, declaring_type_token: field.declaring_type_token, @@ -410,7 +419,7 @@ const pinvokeNode = ( ), observations: [ { - label: pinvoke.import_name, + label: metadataLabel(pinvoke.import_name), properties: { token: pinvoke.token, member_token: pinvoke.member_token, @@ -441,7 +450,7 @@ const nativeImplementationNode = ( ), observations: [ { - label: implementation.name, + label: metadataLabel(implementation.name), properties: { token: implementation.token, name: implementation.name, diff --git a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts index c235356431..c5d49eb9f9 100644 --- a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts +++ b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts @@ -301,6 +301,51 @@ describe("managed application graph identity facts", () => { ), ).toEqual([]); }); + + it("projects a module whose metadata name is empty without a label", () => { + const bytes = buildManagedPeFixture({ ilBody: Buffer.from([0x06, 0x2a]) }); + const control = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes, "/fixture/EmptyModuleName.dll"), + ); + if (control.module === null) throw new Error("Expected a Module row"); + // Point the Module row's Name at the string heap's empty entry. + bytes.writeUInt16LE(0, control.module.row_offset + 2); + const binary = managedPeFixtureTarget( + bytes, + "/fixture/EmptyModuleName.dll", + ); + const members = inspectManagedMembersBytes(bytes, binary); + expect(members.module?.name).toBe(""); + const projected = projectManagedApplicationGraphEvidence({ + managed_members: createEvidence(binary, MANAGED_STATIC_PROVIDER, { + operation: "inspect_managed_members", + parameters: {}, + result: members, + rawResult: null, + limitations: members.limitations, + locations: [{ kind: "artifact-path", path: binary.path }], + }), + }); + if (!projected.ok) throw projected.error; + const graph = parseJavaScriptApplicationGraph( + managedApplicationGraphResultSchema.parse( + parseEvidence(projected.value).normalized_result, + ).graph, + ); + expect( + graph.nodes.find(({ kind }) => kind === "managed-module") + ?.observations[0], + ).toMatchObject({ label: null, properties: { name: "" } }); + expect(graph.nodes.map(({ kind }) => kind)).toEqual( + expect.arrayContaining([ + "managed-module", + "managed-type", + "managed-method", + "managed-field", + ]), + ); + }); }); it("normalizes MVID casing for node identity while preserving observed values", () => { From 933f216698fc7162c234e22fefe97315faa80676 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 22:56:56 -0700 Subject: [PATCH 20/21] feat(kuna): add Kuna readiness, doctor and owned project cache (#3832) Add caller-supplied Kuna readiness, scoped doctor support and a verified whole-program export cache. Index function byte ranges without introducing analysis-tool responses, and retain unresolved process ownership through cleanup failures. Separate retryable file cleanup from unproven process cleanup. Verified real Kuna 1.833 on Debian 12 x86-64 with coreutils, SQLite and zlib; 5,207 exported function spans, cold/warm performance, raw-export parity and lifecycle failures. Refs #1957. Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- src/application/DoctorProviderIds.ts | 8 +- src/doctorRuntime.test.ts | 21 + src/doctorRuntime.ts | 2 + src/kuna/KunaConfiguration.ts | 144 ++++ src/kuna/KunaDoctor.ts | 211 ++++++ src/kuna/KunaInstallation.ts | 191 +++++ src/kuna/KunaProjectCache.ts | 690 +++++++++++++++++ src/kuna/KunaProjectExport.ts | 466 ++++++++++++ src/kuna/KunaProjectFiles.ts | 189 +++++ src/kuna/KunaRelease.ts | 16 + tests/boundary/providers/kuna/fakeKuna.ts | 125 ++++ .../providers/kuna/kunaDoctor.test.ts | 136 ++++ .../providers/kuna/kunaInstallation.test.ts | 212 ++++++ .../providers/kuna/kunaProjectCache.test.ts | 704 ++++++++++++++++++ .../kuna/kunaProjectConcurrency.test.ts | 252 +++++++ .../providers/kuna/kunaProjectFiles.test.ts | 164 ++++ 16 files changed, 3530 insertions(+), 1 deletion(-) create mode 100644 src/kuna/KunaConfiguration.ts create mode 100644 src/kuna/KunaDoctor.ts create mode 100644 src/kuna/KunaInstallation.ts create mode 100644 src/kuna/KunaProjectCache.ts create mode 100644 src/kuna/KunaProjectExport.ts create mode 100644 src/kuna/KunaProjectFiles.ts create mode 100644 src/kuna/KunaRelease.ts create mode 100644 tests/boundary/providers/kuna/fakeKuna.ts create mode 100644 tests/boundary/providers/kuna/kunaDoctor.test.ts create mode 100644 tests/boundary/providers/kuna/kunaInstallation.test.ts create mode 100644 tests/boundary/providers/kuna/kunaProjectCache.test.ts create mode 100644 tests/boundary/providers/kuna/kunaProjectConcurrency.test.ts create mode 100644 tests/boundary/providers/kuna/kunaProjectFiles.test.ts diff --git a/src/application/DoctorProviderIds.ts b/src/application/DoctorProviderIds.ts index af31f4c683..06a4f13a68 100644 --- a/src/application/DoctorProviderIds.ts +++ b/src/application/DoctorProviderIds.ts @@ -1,2 +1,8 @@ /** Deep-analysis providers whose readiness can be selected explicitly by doctor. */ -export const DOCTOR_PROVIDER_IDS = ["hopper", "ghidra", "ida", "jadx"] as const; +export const DOCTOR_PROVIDER_IDS = [ + "hopper", + "ghidra", + "ida", + "jadx", + "kuna", +] as const; diff --git a/src/doctorRuntime.test.ts b/src/doctorRuntime.test.ts index c62bd0f5cb..265c96c5e1 100644 --- a/src/doctorRuntime.test.ts +++ b/src/doctorRuntime.test.ts @@ -42,6 +42,27 @@ it("omits REA_JADX_MCP_JAR from the provider registration environment when it is expect(environment).not.toHaveProperty("REA_JADX_MCP_JAR"); }); +it("carries a validated REA_KUNA_COMMAND into the provider registration environment", async () => { + expect(DOCTOR_PROVIDER_IDS).toContain("kuna"); + const root = await createTestTempDirectory("rea-kuna-registration-"); + const kuna = join(root, "kuna"); + await writeFile(kuna, "#!/bin/sh\nprintf 'kuna 1.833\\n'\n", { mode: 0o700 }); + + const inspections = await createSystemDoctorHost({ + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + }).providerInspections?.(); + + expect(inspections?.find(({ id }) => id === "kuna")).toMatchObject({ + available: true, + providerVersion: "1.833", + }); + expect(providerRegistrationEnvironment(inspections ?? [])).toMatchObject({ + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + }); +}); + const createFakeJdk = async (root: string): Promise => { const javaHome = join(root, "fake-jdk"); const bin = join(javaHome, "bin"); diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts index b6ab62d639..e85238db4f 100644 --- a/src/doctorRuntime.ts +++ b/src/doctorRuntime.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { inspectJadxProvider } from "./android/JadxDoctor.js"; import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js"; import { inspectIdaRegistration } from "./ida/IdaDoctor.js"; +import { inspectKunaProvider } from "./kuna/KunaDoctor.js"; import { readIdaConfiguration } from "./ida/IdaConfiguration.js"; import { parseJebMcpEndpoint } from "./jeb/JebMcpEndpoint.js"; import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js"; @@ -45,6 +46,7 @@ export const createSystemDoctorHost = ( await inspectSystemGhidraProvider(environment), inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG), await inspectJadxProvider(environment), + await inspectKunaProvider(environment), ], linuxDemoRuntimeCheck: () => inspectLinuxPrivateDisplay(environment), }); diff --git a/src/kuna/KunaConfiguration.ts b/src/kuna/KunaConfiguration.ts new file mode 100644 index 0000000000..48fe418645 --- /dev/null +++ b/src/kuna/KunaConfiguration.ts @@ -0,0 +1,144 @@ +import { isAbsolute, join } from "node:path"; + +import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; +import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + KUNA_COMMAND_REMEDIATION, + KUNA_UNSUPPORTED_HOST_REMEDIATION, +} from "./KunaRelease.js"; + +/** Kuna caps its own decode lanes and worker pool at 32. */ +const MAX_KUNA_JOBS = 32; + +/** + * A 1.4 MB sqlite3 shell exported serially in 66-108 s on Apple M-series and + * Linux hosts; thirty minutes admits images an order of magnitude larger + * without an unbounded run. Larger images need a raised deadline or more jobs. + */ +const DEFAULT_PROJECT_TIMEOUT_MS = 30 * 60 * 1000; + +/** + * A serial (`--jobs 1`) export is Kuna's reference answer: it decompiles + * callees before callers, which recovers prototypes that parallel workers + * cannot. Measured on coreutils sort, 40-60% of functions differ otherwise. + */ +const DEFAULT_JOBS = 1; + +/** Measured sqlite3 exports occupy ~25 MiB; four GiB retains many projects. */ +const DEFAULT_CACHE_MAX_BYTES = 4 * 1024 ** 3; + +/** One project needs its .c, .h, .asm, inventory and manifest; refuse smaller caps. */ +const MIN_CACHE_MAX_BYTES = 1024 ** 2; + +/** Caller-selected Kuna installation and REA-owned project cache policy. */ +export interface KunaConfiguration { + /** Caller-selected executable path, preserved exactly as configured. */ + readonly command: string; + /** REA-owned cache root; Kuna's own spec cache lives beneath it too. */ + readonly cacheRoot: string; + readonly cacheMaxBytes: number; + readonly projectTimeoutMs: number; + readonly jobs: number; +} + +/** Why the configured environment cannot select a Kuna installation. */ +export interface KunaConfigurationFailure { + readonly code: "not_configured" | "open_options_invalid" | "unsupported_host"; + readonly reason: string; + readonly setting: string | null; +} + +const failure = ( + code: KunaConfigurationFailure["code"], + reason: string, + setting: string | null = null, +): Result => err({ code, reason, setting }); + +const positiveInteger = (value: string): number | undefined => + /^[1-9]\d*$/u.test(value) && Number.isSafeInteger(Number(value)) + ? Number(value) + : undefined; + +/** Per-user cache directory that REA owns for Kuna on this host. */ +const defaultCacheRoot = ( + environment: Readonly, + platform: NodeJS.Platform, +): string => { + const home = homeDirectoryFromEnvironment(environment, platform); + if (platform === "darwin") + return join(home, "Library", "Caches", "rea", "kuna"); + const xdg = environment.XDG_CACHE_HOME; + return join( + xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".cache"), + "rea", + "kuna", + ); +}; + +/** Parse the Kuna settings without touching the filesystem or the executable. */ +export const readKunaConfiguration = ( + environment: Readonly, + platform: NodeJS.Platform = process.platform, +): Result => { + const command = environment.REA_KUNA_COMMAND; + if (command === undefined || command.length === 0) + return failure("not_configured", KUNA_COMMAND_REMEDIATION); + if (!isAbsolute(command)) + return failure( + "open_options_invalid", + `REA_KUNA_COMMAND must be an absolute path; received ${command}. ${KUNA_COMMAND_REMEDIATION}`, + "REA_KUNA_COMMAND", + ); + if (platform !== "darwin" && platform !== "linux") + return failure("unsupported_host", KUNA_UNSUPPORTED_HOST_REMEDIATION); + + const cacheRoot = environment.REA_KUNA_CACHE_DIR; + if (cacheRoot !== undefined && !isAbsolute(cacheRoot)) + return failure( + "open_options_invalid", + `REA_KUNA_CACHE_DIR must be an absolute directory; received ${cacheRoot}.`, + "REA_KUNA_CACHE_DIR", + ); + + const rawMaxBytes = environment.REA_KUNA_CACHE_MAX_BYTES; + const cacheMaxBytes = + rawMaxBytes === undefined + ? DEFAULT_CACHE_MAX_BYTES + : positiveInteger(rawMaxBytes); + if (cacheMaxBytes === undefined || cacheMaxBytes < MIN_CACHE_MAX_BYTES) + return failure( + "open_options_invalid", + `REA_KUNA_CACHE_MAX_BYTES must be a decimal byte count of at least ${String(MIN_CACHE_MAX_BYTES)}; received ${String(rawMaxBytes)}.`, + "REA_KUNA_CACHE_MAX_BYTES", + ); + + const rawTimeout = environment.REA_KUNA_PROJECT_TIMEOUT_MS; + const timeout = + rawTimeout === undefined + ? undefined + : providerStartupTimeoutSchema.safeParse(rawTimeout); + if (timeout !== undefined && !timeout.success) + return failure( + "open_options_invalid", + `REA_KUNA_PROJECT_TIMEOUT_MS must be a decimal millisecond count from 1 to 2147483647; received ${String(rawTimeout)}.`, + "REA_KUNA_PROJECT_TIMEOUT_MS", + ); + + const rawJobs = environment.REA_KUNA_JOBS; + const jobs = rawJobs === undefined ? DEFAULT_JOBS : positiveInteger(rawJobs); + if (jobs === undefined || jobs > MAX_KUNA_JOBS) + return failure( + "open_options_invalid", + `REA_KUNA_JOBS must be an integer from 1 to ${String(MAX_KUNA_JOBS)}; received ${String(rawJobs)}.`, + "REA_KUNA_JOBS", + ); + + return ok({ + command, + cacheRoot: cacheRoot ?? defaultCacheRoot(environment, platform), + cacheMaxBytes, + projectTimeoutMs: timeout?.data ?? DEFAULT_PROJECT_TIMEOUT_MS, + jobs, + }); +}; diff --git a/src/kuna/KunaDoctor.ts b/src/kuna/KunaDoctor.ts new file mode 100644 index 0000000000..4c3488dfb2 --- /dev/null +++ b/src/kuna/KunaDoctor.ts @@ -0,0 +1,211 @@ +import { constants } from "node:fs"; +import { access, stat } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { + DoctorProviderCheck, + DoctorProviderInspection, +} from "../application/Doctor.js"; +import { readKunaConfiguration } from "./KunaConfiguration.js"; +import { + inspectKunaInstallation, + type KunaReadinessFailure, +} from "./KunaInstallation.js"; +import { + KUNA_COMMAND_REMEDIATION, + KUNA_PROVIDER_ID, + KUNA_UNSUPPORTED_HOST_REMEDIATION, + KUNA_VERIFIED_VERSION, + KUNA_VERSION_PROBE_REMEDIATION, +} from "./KunaRelease.js"; + +/** Settings a registration may carry; only those the caller set explicitly. */ +const REGISTRATION_SETTINGS = [ + "REA_KUNA_COMMAND", + "REA_KUNA_CACHE_DIR", + "REA_KUNA_CACHE_MAX_BYTES", + "REA_KUNA_PROJECT_TIMEOUT_MS", + "REA_KUNA_JOBS", +] as const; + +/** Project Kuna readiness into the doctor contract without writing anything. */ +export const inspectKunaProvider = async ( + environment: Readonly, + platform: NodeJS.Platform = process.platform, +): Promise => { + const installation = await inspectKunaInstallation(environment, { + platform, + }); + const configured = + environment.REA_KUNA_COMMAND !== undefined && + environment.REA_KUNA_COMMAND.length > 0; + if (!installation.ok) + return { + id: KUNA_PROVIDER_ID, + configured, + available: false, + providerVersion: null, + registrationEnvironment: {}, + checks: [installationFailure(installation.error, configured)], + }; + const { value } = installation; + return { + id: KUNA_PROVIDER_ID, + configured, + available: true, + providerVersion: value.version, + registrationEnvironment: Object.fromEntries( + REGISTRATION_SETTINGS.flatMap((name) => { + const setting = environment[name]; + return setting === undefined ? [] : [[name, setting]]; + }), + ), + checks: [ + { + name: "installation", + ok: true, + code: null, + detail: `${value.executable} reports Kuna ${value.version} (sha256 ${value.sha256})${ + value.verified + ? "." + : `; REA verified ${KUNA_VERIFIED_VERSION}, so this release's project layout is unverified.` + }`, + remediation: null, + classification: "config_drift", + details: { + executable: value.executable, + version: value.version, + verified_version: KUNA_VERIFIED_VERSION, + sha256: value.sha256, + }, + }, + await cacheCheck(environment, platform), + ], + }; +}; + +const installationFailure = ( + failure: KunaReadinessFailure, + configured: boolean, +): DoctorProviderCheck => ({ + name: "installation", + ok: false, + code: failure.code, + detail: failure.reason, + remediation: + failure.code === "unsupported_host" + ? KUNA_UNSUPPORTED_HOST_REMEDIATION + : failure.code === "version_unresolved" + ? KUNA_VERSION_PROBE_REMEDIATION + : KUNA_COMMAND_REMEDIATION, + classification: + failure.code === "unsupported_host" + ? "unsupported_host" + : configured + ? "config_drift" + : "missing_analysis_engine", + details: failure.diagnostics, +}); + +/** The cache root may not exist yet; doctor reports it without creating it. */ +const cacheCheck = async ( + environment: Readonly, + platform: NodeJS.Platform, +): Promise => { + const configuration = readKunaConfiguration(environment, platform); + if (!configuration.ok) + return { + name: "cache", + ok: false, + code: configuration.error.code, + detail: configuration.error.reason, + remediation: KUNA_COMMAND_REMEDIATION, + classification: "config_drift", + }; + const { cacheRoot, cacheMaxBytes } = configuration.value; + const details = { cache_root: cacheRoot, cache_max_bytes: cacheMaxBytes }; + try { + if (!(await stat(cacheRoot)).isDirectory()) + return { + name: "cache", + ok: false, + code: "open_options_invalid", + detail: `The Kuna cache root ${cacheRoot} exists but is not a directory.`, + remediation: + "Set REA_KUNA_CACHE_DIR to a directory REA may own, or remove the conflicting file.", + classification: "config_drift", + details, + }; + await access(cacheRoot, constants.W_OK | constants.X_OK); + } catch (cause: unknown) { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") { + const creatable = await nearestDirectoryWritable(cacheRoot); + return creatable.ok + ? { + name: "cache", + ok: true, + code: null, + detail: `REA creates the Kuna project cache at ${cacheRoot} on first use.`, + remediation: null, + classification: "config_drift", + details, + } + : { + name: "cache", + ok: false, + code: "open_options_invalid", + detail: `REA cannot create the Kuna cache root ${cacheRoot}: ${creatable.reason}`, + remediation: + "Set REA_KUNA_CACHE_DIR to a directory the REA process user can create or write.", + classification: "config_drift", + details, + }; + } + return { + name: "cache", + ok: false, + code: "open_options_invalid", + detail: `REA cannot write the Kuna cache root ${cacheRoot}: ${cause instanceof Error ? cause.message : String(cause)}`, + remediation: + "Set REA_KUNA_CACHE_DIR to a directory the REA process user can write.", + classification: "config_drift", + details, + }; + } + return { + name: "cache", + ok: true, + code: null, + detail: `Kuna project exports are cached in ${cacheRoot} (at most ${String(cacheMaxBytes)} bytes).`, + remediation: null, + classification: "config_drift", + details, + }; +}; + +/** Whether the nearest existing ancestor is a directory REA may create entries in. */ +const nearestDirectoryWritable = async ( + path: string, +): Promise< + { readonly ok: true } | { readonly ok: false; readonly reason: string } +> => { + let candidate = dirname(path); + for (;;) { + try { + if (!(await stat(candidate)).isDirectory()) + return { ok: false, reason: `${candidate} is not a directory.` }; + await access(candidate, constants.W_OK | constants.X_OK); + return { ok: true }; + } catch (cause: unknown) { + const missing = + cause instanceof Error && "code" in cause && cause.code === "ENOENT"; + const parent = dirname(candidate); + if (!missing || parent === candidate) + return { + ok: false, + reason: `${candidate} is not writable: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + candidate = parent; + } + } +}; diff --git a/src/kuna/KunaInstallation.ts b/src/kuna/KunaInstallation.ts new file mode 100644 index 0000000000..5ba0c58b18 --- /dev/null +++ b/src/kuna/KunaInstallation.ts @@ -0,0 +1,191 @@ +import { constants } from "node:fs"; +import { access, open, realpath, stat } from "node:fs/promises"; +import { join } from "node:path"; + +import type { JsonValue } from "../domain/jsonValue.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { + execFileOutput, + execFileOutputFailure, +} from "../process/ExecFileOutput.js"; +import { + readKunaConfiguration, + type KunaConfiguration, + type KunaConfigurationFailure, +} from "./KunaConfiguration.js"; +import { + KUNA_COMMAND_REMEDIATION, + KUNA_VERIFIED_VERSION, + KUNA_VERSION_PROBE_REMEDIATION, +} from "./KunaRelease.js"; + +/** `kuna --version` exits before loading specs or an input; it needs no budget beyond this. */ +const VERSION_PROBE_TIMEOUT_MS = 10_000; +const VERSION_PROBE_MAX_OUTPUT_BYTES = 64 * 1024; + +/** Release CI prints exactly `kuna `; source builds print the Cargo version. */ +const VERSION_LINE = /^kuna (\d+\.\d+(?:\.\d+)?)\r?\n?$/u; + +/** One observed Kuna executable, identified by its bytes as well as its version. */ +export interface KunaInstallation { + readonly configuration: KunaConfiguration; + /** Canonical path of the probed executable. */ + readonly executable: string; + readonly version: string; + /** Whether this release is the one REA verified. */ + readonly verified: boolean; + readonly sha256: string; + readonly bytes: number; + /** + * Whether the executable is a `#!` script, such as a package-manager shim. + * Its process then reports the interpreter as its command, so ownership + * checks cannot match the configured path by argv prefix. + */ + readonly script: boolean; +} + +/** Why the selected Kuna installation cannot be used. */ +export interface KunaReadinessFailure { + readonly code: + | KunaConfigurationFailure["code"] + | "executable_missing" + | "version_unresolved"; + readonly reason: string; + readonly diagnostics: Readonly>; +} + +/** + * Environment for every Kuna child. Kuna unpacks its built-in SLEIGH tree into + * the per-user cache under HOME (macOS) or XDG_CACHE_HOME (Linux), so both + * point inside REA's own cache root instead of the user's. + */ +export const kunaChildEnvironment = ( + configuration: KunaConfiguration, +): NodeJS.ProcessEnv => { + const home = join(configuration.cacheRoot, "home"); + return { + HOME: home, + XDG_CACHE_HOME: join(home, ".cache"), + NO_COLOR: "1", + }; +}; + +const failure = ( + code: KunaReadinessFailure["code"], + reason: string, + diagnostics: Readonly>, +): Result => err({ code, reason, diagnostics }); + +const message = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +/** Resolve, version-probe and fingerprint the configured executable. */ +export const inspectKunaInstallation = async ( + environment: Readonly, + options: { + readonly platform?: NodeJS.Platform; + readonly signal?: AbortSignal; + /** Probe deadline; tests shorten it. */ + readonly probeTimeoutMs?: number; + } = {}, +): Promise> => { + const configuration = readKunaConfiguration(environment, options.platform); + if (!configuration.ok) + return failure(configuration.error.code, configuration.error.reason, { + configured: configuration.error.code !== "not_configured", + ...(configuration.error.setting === null + ? {} + : { setting: configuration.error.setting }), + }); + const { command } = configuration.value; + + let executable: string; + try { + executable = await realpath(command); + await access(executable, constants.X_OK); + if (!(await stat(executable)).isFile()) + return failure( + "executable_missing", + `REA_KUNA_COMMAND is not a regular file: ${command}. ${KUNA_COMMAND_REMEDIATION}`, + { command }, + ); + } catch (cause: unknown) { + options.signal?.throwIfAborted(); + return failure( + "executable_missing", + `Cannot execute REA_KUNA_COMMAND ${command}: ${message(cause)}. ${KUNA_COMMAND_REMEDIATION}`, + { command }, + ); + } + + const deadline = options.probeTimeoutMs ?? VERSION_PROBE_TIMEOUT_MS; + let stdout: string; + try { + ({ stdout } = await execFileOutput(executable, ["--version"], { + env: kunaChildEnvironment(configuration.value), + timeout: deadline, + maxBuffer: VERSION_PROBE_MAX_OUTPUT_BYTES, + // Escalate to SIGKILL so an executable that ignores SIGTERM cannot hang doctor. + stopSignal: "SIGTERM", + ...(options.signal === undefined ? {} : { signal: options.signal }), + })); + } catch (cause: unknown) { + options.signal?.throwIfAborted(); + const output = execFileOutputFailure(cause); + return failure( + "version_unresolved", + `${executable} --version failed: ${output?.stderr.trim() || message(cause)}. ${KUNA_VERSION_PROBE_REMEDIATION}`, + { + command, + executable, + exit_code: typeof output?.code === "number" ? output.code : null, + signal: output?.signal ?? null, + killed: output?.killed ?? false, + output_truncated: output?.outputTruncated ?? false, + deadline_ms: deadline, + }, + ); + } + const version = VERSION_LINE.exec(stdout)?.[1]; + if (version === undefined) + return failure( + "version_unresolved", + `${executable} --version printed no recognizable Kuna version. ${KUNA_VERSION_PROBE_REMEDIATION}`, + { command, executable, stdout: stdout.slice(0, 256) }, + ); + + let fingerprint: { readonly sha256: string; readonly bytes: number }; + let script: boolean; + try { + fingerprint = await hashStableFile(executable, options.signal); + script = await startsWithShebang(executable); + } catch (cause: unknown) { + options.signal?.throwIfAborted(); + return failure( + "executable_missing", + `Cannot fingerprint ${executable}: ${message(cause)}`, + { command, executable }, + ); + } + + return ok({ + configuration: configuration.value, + executable, + version, + verified: version === KUNA_VERIFIED_VERSION, + ...fingerprint, + script, + }); +}; + +const startsWithShebang = async (path: string): Promise => { + const handle = await open(path, "r"); + try { + const prefix = Buffer.alloc(2); + const { bytesRead } = await handle.read(prefix, 0, 2, 0); + return bytesRead === 2 && prefix.toString("latin1") === "#!"; + } finally { + await handle.close(); + } +}; diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts new file mode 100644 index 0000000000..9bdcb406f4 --- /dev/null +++ b/src/kuna/KunaProjectCache.ts @@ -0,0 +1,690 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + rename, + rm, + stat, + utimes, +} from "node:fs/promises"; +import { join } from "node:path"; + +import canonicalize from "canonicalize"; +import writeFileAtomic from "write-file-atomic"; +import { z } from "zod"; + +import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { hashStableFile } from "../filesystem/StableFileHash.js"; +import type { KunaInstallation } from "./KunaInstallation.js"; +import { + exportKunaProject, + KUNA_EXPORT_OPERATION, + kunaExportFailure, + readKunaExport, + type KunaExport, + type KunaExportStatus, + type KunaProcessLauncher, +} from "./KunaProjectExport.js"; +import type { + KunaInventoryFunction, + KunaProjectFunction, +} from "./KunaProjectFiles.js"; +import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; + +/** Bumped whenever the entry layout or manifest meaning changes. */ +const CACHE_LAYOUT = 1; + +/** A manifest names five files and their digests; anything larger is not ours. */ +const MANIFEST_MAX_BYTES = 64 * 1024; + +const KEY_PATTERN = /^[0-9a-f]{64}$/u; + +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; + +/** Inputs that determine an export's content. Each one is part of the cache key. */ +export interface KunaProjectKeyInputs { + readonly targetSha256: string; + readonly kunaSha256: string; + readonly kunaVersion: string; + /** + * `--jobs 1` is Kuna's reference answer; more jobs decompile without + * callee-first prototypes and change some bodies. + */ + readonly jobs: number; +} + +/** Content address of one export: every input that can change its bytes. */ +export const kunaProjectKey = (inputs: KunaProjectKeyInputs): string => + createHash("sha256") + .update( + canonicalize({ + layout: CACHE_LAYOUT, + target_sha256: inputs.targetSha256, + kuna_sha256: inputs.kunaSha256, + kuna_version: inputs.kunaVersion, + export: { command: "decompile-project", jobs: inputs.jobs }, + }) ?? "", + ) + .digest("hex"); + +const fileSchema = z.strictObject({ + path: z.string().min(1), + bytes: z.number().int().nonnegative(), + sha256: z.string().regex(SHA256_PATTERN), +}); + +const manifestSchema = z.strictObject({ + layout: z.literal(CACHE_LAYOUT), + key: z.string().regex(KEY_PATTERN), + inputs: z.strictObject({ + target_sha256: z.string().regex(SHA256_PATTERN), + kuna_sha256: z.string().regex(SHA256_PATTERN), + kuna_version: z.string().min(1), + jobs: z.number().int().positive(), + }), + files: z.strictObject({ + c: fileSchema, + h: fileSchema, + asm: fileSchema, + readme: fileSchema, + inventory: fileSchema, + }), + summary: z.strictObject({ + decompiled: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + }), + created_at: z.string().datetime(), +}); + +/** Immutable description of one published export, written once at publish. */ +export type KunaProjectManifest = z.infer; + +/** One verified, published export. */ +export interface KunaProject { + readonly key: string; + /** Directory holding the export's `.c`, `.h`, `.asm`, README and inventory. */ + readonly directory: string; + readonly manifest: KunaProjectManifest; + /** Decompiled function blocks in `.c` order. */ + readonly functions: readonly KunaProjectFunction[]; + /** Every callable entry Kuna found, including imports it does not decompile. */ + readonly inventory: readonly KunaInventoryFunction[]; +} + +/** One request to produce or reuse the export for a target. */ +export interface KunaProjectRequest { + readonly installation: KunaInstallation; + /** Caller-selected target path; REA decompiles a verified copy of it. */ + readonly targetPath: string; + readonly targetSha256: string; + readonly signal?: AbortSignal; + readonly onStatus?: (status: KunaExportStatus) => void; +} + +interface StagedResult { + readonly result: Result; + /** Set when an owned process may still write into staging. */ + readonly retainStaging: boolean; +} + +interface InflightBuild { + readonly key: string; + readonly promise: Promise>; + readonly controller: AbortController; + readonly listeners: Set<(status: KunaExportStatus) => void>; + waiters: number; +} + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR"); + +/** A file an entry's manifest names is gone, replaced or changed under it. */ +const isDamaged = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + ["ENOENT", "ENOTDIR", "EISDIR", "ENOTFILE", "ESTALE"].includes( + String(cause.code), + ); + +const isPublishedByAnother = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + (cause.code === "EEXIST" || cause.code === "ENOTEMPTY"); + +const parseManifest = (text: string): KunaProjectManifest | undefined => { + try { + const parsed = manifestSchema.safeParse(JSON.parse(text)); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +}; + +const manifestBytes = (manifest: KunaProjectManifest): number => + Object.values(manifest.files).reduce((sum, file) => sum + file.bytes, 0); + +/** + * REA-owned on-disk cache of `kuna decompile-project` exports. + * + * Layout under the root: `projects//` holds published entries + * (`manifest.json` plus the export and inventory in `project/`), `staging/` + * holds builds whose processes may still be live, `trash/` holds retired + * workspaces and evicted entries awaiting removal, + * and `home/` is Kuna's own HOME so its spec cache stays inside REA's + * ownership. + * + * Entries are content-addressed and immutable. A build runs in private + * staging and is published by one directory rename, so a reader sees a + * complete entry or none, and concurrent publishers of one key keep the first. + * File digests are recorded once at publish and checked on every first read. + * Recency is the entry directory's mtime; publishing evicts the least recently + * used entries above the size cap, never an entry pinned by this process. + */ +export class KunaProjectCache { + readonly #root: string; + readonly #maxBytes: number; + readonly #launcher: KunaProcessLauncher | undefined; + /** Builds new requests may join, at most one per key. */ + readonly #inflight = new Map(); + /** Every build not yet settled, including abandoned ones a retry replaced. */ + readonly #unfinished = new Set(); + readonly #verified = new Map(); + readonly #pins = new Map(); + /** One eviction decision at a time; exports themselves remain concurrent. */ + #eviction: Promise = Promise.resolve(); + #capacity: "within_limit" | "pinned" | "building" = "within_limit"; + #maintenance: Promise | undefined; + #maintenanceRequested = false; + readonly #retainedProcesses: ProviderCleanupError[] = []; + /** Only resources whose process cleanup is proven may be retried as files. */ + readonly #retainedFiles = new Map(); + + constructor( + root: string, + options: { + readonly maxBytes: number; + readonly launcher?: KunaProcessLauncher; + }, + ) { + this.#root = root; + this.#maxBytes = options.maxBytes; + this.#launcher = options.launcher; + } + + /** Keep an entry from eviction while this process serves it. */ + pin(key: string): () => void { + this.#pins.set(key, (this.#pins.get(key) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const count = (this.#pins.get(key) ?? 1) - 1; + if (count === 0) { + this.#pins.delete(key); + if (this.#capacity !== "within_limit") this.#scheduleEviction(); + } else this.#pins.set(key, count); + }; + } + + /** + * Return the verified published entry for a key, or undefined when none is + * usable. A corrupt or tampered entry is removed and reported as absent. + */ + async lookup( + key: string, + signal?: AbortSignal, + ): Promise { + const verified = this.#verified.get(key); + if (verified !== undefined) { + // Another process may have evicted it since this one verified it. + if (await this.#touch(key)) return verified; + this.#verified.delete(key); + return undefined; + } + const entry = join(this.#root, "projects", key); + let text: string; + try { + text = await this.#readBounded(join(entry, "manifest.json")); + } catch (cause: unknown) { + if (isMissing(cause)) return undefined; + if (!(cause instanceof RangeError)) throw cause; + await this.#discard(key); + return undefined; + } + const manifest = parseManifest(text); + if (manifest?.key !== key) { + await this.#discard(key); + return undefined; + } + const directory = join(entry, "project"); + for (const file of Object.values(manifest.files)) { + let observed: { readonly sha256: string; readonly bytes: number }; + try { + observed = await hashStableFile(join(directory, file.path), signal); + } catch (cause: unknown) { + signal?.throwIfAborted(); + if (!isDamaged(cause)) throw cause; + await this.#discard(key); + return undefined; + } + if (observed.sha256 !== file.sha256 || observed.bytes !== file.bytes) { + await this.#discard(key); + return undefined; + } + } + const parsed = await readKunaExport( + directory, + manifest.files, + manifest.summary, + signal, + ); + if (!parsed.ok) { + await this.#discard(key); + return undefined; + } + const project = { key, directory, manifest, ...parsed.value }; + if (!(await this.#touch(key))) return undefined; + this.#verified.set(key, project); + return project; + } + + /** + * Return the entry for a request, building and publishing it when absent. + * Concurrent requests for one key share one Kuna run; the run is cancelled + * only when every waiting request has been cancelled. + */ + async build( + request: KunaProjectRequest, + ): Promise> { + if (request.signal?.aborted === true) + return err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION)); + const key = kunaProjectKey({ + targetSha256: request.targetSha256, + kunaSha256: request.installation.sha256, + kunaVersion: request.installation.version, + jobs: request.installation.configuration.jobs, + }); + const running = this.#inflight.get(key); + // A build whose waiters all left is winding down; a new request starts afresh. + if (running !== undefined && !running.controller.signal.aborted) + return this.#await(running, request); + const controller = new AbortController(); + const listeners = new Set<(status: KunaExportStatus) => void>(); + const promise = this.#produce(key, request, controller.signal, (status) => { + for (const listener of listeners) listener(status); + }); + const created: InflightBuild = { + key, + promise, + controller, + listeners, + waiters: 0, + }; + this.#inflight.set(key, created); + this.#unfinished.add(created); + void promise.then(() => { + this.#unfinished.delete(created); + if (this.#inflight.get(key) === created) this.#inflight.delete(key); + if (this.#unfinished.size === 0 && this.#capacity === "building") + this.#scheduleEviction(); + }); + return this.#await(created, request); + } + + /** Wait for builds, retry safe file removal, and report unresolved cleanup. */ + async close(): Promise> { + await Promise.allSettled( + [...this.#unfinished].map((entry) => entry.promise), + ); + while (this.#maintenance !== undefined) await this.#maintenance; + for (const path of [...this.#retainedFiles.keys()]) + await this.#removeRetired(path).catch(() => undefined); + const retained = [ + ...this.#retainedProcesses, + ...this.#retainedFiles.values(), + ]; + if (retained.length === 0) return ok(null); + return err( + new ProviderCleanupError( + KUNA_PROVIDER_ID, + retained.flatMap((entry) => entry.cleanupResources), + { + reason: retained + .map((entry) => + String(entry.diagnostics?.["reason"] ?? entry.message), + ) + .join("; "), + }, + { operation: KUNA_EXPORT_OPERATION }, + ), + ); + } + + #await( + entry: InflightBuild, + request: KunaProjectRequest, + ): Promise> { + entry.waiters += 1; + const onStatus = request.onStatus; + // Each waiter owns its registration, even when callers reuse a callback. + const listener = + onStatus === undefined + ? undefined + : (status: KunaExportStatus) => onStatus(status); + if (listener !== undefined) entry.listeners.add(listener); + return new Promise((resolve) => { + let settled = false; + const leave = () => { + if (listener !== undefined) entry.listeners.delete(listener); + entry.waiters -= 1; + }; + const onAbort = () => { + if (settled) return; + settled = true; + leave(); + if (entry.waiters === 0) entry.controller.abort(); + resolve(err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION))); + }; + request.signal?.addEventListener("abort", onAbort, { once: true }); + void entry.promise.then((result) => { + request.signal?.removeEventListener("abort", onAbort); + if (settled) return; + settled = true; + leave(); + resolve(result); + }); + }); + } + + async #produce( + key: string, + request: KunaProjectRequest, + signal: AbortSignal, + onStatus: (status: KunaExportStatus) => void, + ): Promise> { + let staging: string; + try { + const existing = await this.lookup(key, signal); + if (existing !== undefined) return ok(existing); + await Promise.all( + ["projects", "staging", "trash", "home"].map((name) => + mkdir(join(this.#root, name), { recursive: true, mode: 0o700 }), + ), + ); + await this.#sweep(signal); + staging = await mkdtemp(join(this.#root, "staging", "build-")); + } catch (cause: unknown) { + return err(kunaExportFailure(cause, signal)); + } + const built = await this.#exportAndPublish( + key, + staging, + request, + signal, + onStatus, + ).catch((cause: unknown): StagedResult => ({ + result: err(kunaExportFailure(cause, signal)), + retainStaging: false, + })); + if (built.retainStaging) return built.result; + let cleanupPath = staging; + try { + // Only a proven-stopped build may enter trash. A failed write, crash or + // lost cleanup identity therefore never makes live staging reclaimable. + const retired = join(this.#root, "trash", `build-${randomUUID()}`); + await rename(staging, retired); + cleanupPath = retired; + await this.#removeRetired(retired); + } catch (cause: unknown) { + if (cause instanceof ProviderCleanupError) return err(cause); + return err(this.#retainFileCleanup(cleanupPath, cause)); + } + if (built.result.ok) + // The entry is published either way; a failed eviction retries on the next publish. + await this.#evict(key).catch(() => undefined); + return built.result; + } + + async #exportAndPublish( + key: string, + staging: string, + request: KunaProjectRequest, + signal: AbortSignal, + onStatus: (status: KunaExportStatus) => void, + ): Promise { + const outcome = await exportKunaProject({ + installation: request.installation, + targetPath: request.targetPath, + targetSha256: request.targetSha256, + workspace: staging, + output: join(staging, "entry", "project"), + signal, + onStatus, + launcher: this.#launcher, + }); + if (outcome.kind === "retained") { + this.#retainedProcesses.push(outcome.error); + return { result: err(outcome.error), retainStaging: true }; + } + return { + result: outcome.result.ok + ? await this.#publish( + key, + staging, + request, + outcome.result.value, + signal, + ) + : outcome.result, + retainStaging: false, + }; + } + + /** Record the manifest and publish the staged export under its key. */ + async #publish( + key: string, + staging: string, + request: KunaProjectRequest, + exported: KunaExport, + signal: AbortSignal, + ): Promise> { + const manifest: KunaProjectManifest = { + layout: CACHE_LAYOUT, + key, + inputs: { + target_sha256: request.targetSha256, + kuna_sha256: request.installation.sha256, + kuna_version: request.installation.version, + jobs: request.installation.configuration.jobs, + }, + files: exported.files, + summary: exported.summary, + created_at: new Date().toISOString(), + }; + const total = manifestBytes(manifest); + if (total > this.#maxBytes) + return err( + new AnalysisResourceConstraintError( + KUNA_EXPORT_OPERATION, + "capacity", + `This export occupies ${String(total)} bytes, more than the whole Kuna cache may hold.`, + { cache_max_bytes: this.#maxBytes, export_bytes: total }, + { + remediationAction: + "Raise REA_KUNA_CACHE_MAX_BYTES above the export size, or analyze this target with another provider.", + }, + ), + ); + await writeFileAtomic( + join(staging, "entry", "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + { mode: 0o600 }, + ); + const destination = join(this.#root, "projects", key); + try { + await rename(join(staging, "entry"), destination); + } catch (cause: unknown) { + if (!isPublishedByAnother(cause)) throw cause; + const winner = await this.lookup(key, signal); + if (winner !== undefined) return ok(winner); + throw cause; + } + const project = { + key, + directory: join(destination, "project"), + manifest, + functions: exported.functions, + inventory: exported.inventory, + }; + this.#verified.set(key, project); + return ok(project); + } + + /** + * Remove retired resources only. Age and a missing marker cannot establish + * that a process stopped, so unresolved staging is never swept. + */ + async #sweep(signal: AbortSignal): Promise { + for (const name of await readdir(join(this.#root, "trash"))) { + signal.throwIfAborted(); + await this.#removeRetired(join(this.#root, "trash", name)).catch( + () => undefined, + ); + } + } + + /** Evict least recently used entries above the cap, keeping pinned ones. */ + #evict(keep: string | null): Promise { + const eviction = this.#eviction.then(() => this.#evictUnpinned(keep)); + this.#eviction = eviction.catch(() => undefined); + return eviction; + } + + /** Coalesce deferred reclamation after callers have had a turn to pin results. */ + #scheduleEviction(): void { + this.#maintenanceRequested = true; + if (this.#maintenance !== undefined) return; + const maintenance = (async () => { + while (this.#maintenanceRequested) { + await new Promise((resolve) => setImmediate(resolve)); + this.#maintenanceRequested = false; + await this.#evict(null).catch(() => undefined); + } + })(); + this.#maintenance = maintenance; + void maintenance.then(() => { + if (this.#maintenance === maintenance) this.#maintenance = undefined; + if (this.#maintenanceRequested) this.#scheduleEviction(); + }); + } + + async #evictUnpinned(keep: string | null): Promise { + // A successful build must reach its callers before they can pin its entry. + // Protect every unfinished build while reading this eviction snapshot. + const building = new Set([...this.#unfinished].map(({ key }) => key)); + const entries: { key: string; bytes: number; mtimeMs: number }[] = []; + for (const name of await readdir(join(this.#root, "projects"))) { + if (!KEY_PATTERN.test(name)) continue; + const path = join(this.#root, "projects", name); + const status = await stat(path).catch(() => undefined); + if (status === undefined) continue; + const manifest = await this.#readBounded(join(path, "manifest.json")) + .then(parseManifest) + .catch(() => undefined); + // An owned entry without a valid manifest is unusable; evict it first. + entries.push( + manifest === undefined + ? { key: name, bytes: 0, mtimeMs: -1 } + : { + key: name, + bytes: manifestBytes(manifest), + mtimeMs: status.mtimeMs, + }, + ); + } + let total = entries.reduce((sum, entry) => sum + entry.bytes, 0); + let deferredByBuild = false; + for (const entry of entries.sort((a, b) => a.mtimeMs - b.mtimeMs)) { + if (total <= this.#maxBytes && entry.mtimeMs >= 0) break; + if (building.has(entry.key) || this.#inflight.has(entry.key)) { + deferredByBuild = true; + continue; + } + if (entry.key === keep || this.#pins.has(entry.key)) continue; + await this.#discard(entry.key); + total -= entry.bytes; + } + this.#capacity = + total <= this.#maxBytes + ? "within_limit" + : deferredByBuild + ? "building" + : "pinned"; + } + + /** Atomically retire one owned entry, then remove it. */ + async #discard(key: string): Promise { + this.#verified.delete(key); + const trash = join(this.#root, "trash", `${key}-${randomUUID()}`); + try { + await mkdir(join(this.#root, "trash"), { recursive: true, mode: 0o700 }); + await rename(join(this.#root, "projects", key), trash); + } catch (cause: unknown) { + if (isMissing(cause)) return; + throw cause; + } + await this.#removeRetired(trash); + } + + /** Keep each failed removal owned until that path is successfully removed. */ + async #removeRetired(path: string): Promise { + try { + await rm(path, { recursive: true, force: true }); + this.#retainedFiles.delete(path); + } catch (cause: unknown) { + throw this.#retainFileCleanup(path, cause); + } + } + + /** Mark an entry recently used; false when it is no longer published. */ + async #touch(key: string): Promise { + const now = new Date(); + try { + await utimes(join(this.#root, "projects", key), now, now); + return true; + } catch (cause: unknown) { + // Recency is advisory; only a missing entry changes the answer. + return !isMissing(cause); + } + } + + async #readBounded(path: string): Promise { + const status = await stat(path); + if (status.size > MANIFEST_MAX_BYTES) + throw new RangeError( + `${path} exceeds ${String(MANIFEST_MAX_BYTES)} bytes`, + ); + return readFile(path, "utf8"); + } + + #retainFileCleanup(path: string, cause: unknown): ProviderCleanupError { + const error = new ProviderCleanupError( + KUNA_PROVIDER_ID, + [path], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { operation: KUNA_EXPORT_OPERATION, cause }, + ); + this.#retainedFiles.set(path, error); + return error; + } +} diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts new file mode 100644 index 0000000000..7b45f1c871 --- /dev/null +++ b/src/kuna/KunaProjectExport.ts @@ -0,0 +1,466 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, +} from "../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { hashStableFile } from "../filesystem/StableFileHash.js"; +import { + OwnedCommandFailure, + runOwnedCommand, +} from "../process/OwnedCommand.js"; +import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js"; +import type { + OwnedProviderProcessSpawnOptions, + ProviderProcessSnapshot, + SpawnedOwnedProviderProcess, +} from "../process/ProviderProcess.js"; +import { + kunaChildEnvironment, + type KunaInstallation, +} from "./KunaInstallation.js"; +import { + parseKunaExportSummary, + parseKunaInventory, + scanKunaProjectSource, + type KunaExportSummary, + type KunaInventoryFunction, + type KunaProjectFunction, +} from "./KunaProjectFiles.js"; +import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; + +/** Operation name every export and cache failure reports. */ +export const KUNA_EXPORT_OPERATION = "kuna:decompile-project"; + +/** Kuna prints one summary line per export; notes on a large image stay far below this. */ +const DIAGNOSTIC_BYTES = 8 * 1024 * 1024; + +/** + * `kuna functions --json` costs ~210 bytes per function; this admits about + * 600,000 functions, above the 392,814 of the largest image Kuna documents. + */ +const INVENTORY_BYTES = 128 * 1024 * 1024; + +const INVENTORY_FILE = "functions.json"; + +/** Identity of one file an export consists of. */ +export interface KunaExportFile { + readonly path: string; + readonly bytes: number; + readonly sha256: string; +} + +/** The files of one export, by role. */ +export interface KunaExportFiles { + readonly c: KunaExportFile; + readonly h: KunaExportFile; + readonly asm: KunaExportFile; + readonly readme: KunaExportFile; + readonly inventory: KunaExportFile; +} + +/** One validated export: its files, Kuna's counts and the facts parsed from them. */ +export interface KunaExport { + readonly files: KunaExportFiles; + readonly summary: KunaExportSummary; + /** Decompiled function blocks in `.c` order. */ + readonly functions: readonly KunaProjectFunction[]; + /** Every callable entry Kuna found, including imports it does not decompile. */ + readonly inventory: readonly KunaInventoryFunction[]; +} + +/** The step an export is in; Kuna itself reports no progress until it finishes. */ +export interface KunaExportStatus { + readonly phase: "inventory" | "decompiling" | "sealing"; + /** Callable entries found by the inventory, once known. */ + readonly inventoryFunctions: number | null; +} + +/** Test seam for the owned process launcher; production uses the default. */ +export type KunaProcessLauncher = ( + options: OwnedProviderProcessSpawnOptions, +) => Promise; + +/** + * How an export ended. `retained` means an owned Kuna process could not be + * proven stopped, so it may still write into the workspace, which therefore + * stays with the caller's cleanup owner. + */ +export type KunaExportOutcome = + | { + readonly kind: "done"; + readonly result: Result; + } + | { readonly kind: "retained"; readonly error: ProviderCleanupError }; + +/** One export of a target into a private workspace. */ +export interface KunaExportRequest { + readonly installation: KunaInstallation; + /** Caller-selected target path; Kuna decompiles a verified copy of it. */ + readonly targetPath: string; + readonly targetSha256: string; + /** Private directory for the copy, Kuna's temporary files and its output. */ + readonly workspace: string; + /** Directory, inside the workspace, that receives the export's files. */ + readonly output: string; + readonly signal: AbortSignal; + readonly onStatus: (status: KunaExportStatus) => void; + readonly launcher?: KunaProcessLauncher | undefined; +} + +const message = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +const isMissing = (cause: unknown): boolean => + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR"); + +/** Map an unexpected failure to a typed one, preferring cancellation. */ +export const kunaExportFailure = ( + cause: unknown, + signal: AbortSignal, +): AnalysisError => { + if (signal.aborted) + return new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause }); + if (cause instanceof AnalysisError) return cause; + return new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { + cause, + userMessage: `Kuna project cache failed: ${message(cause)}`, + }); +}; + +const unusable = (reason: string): KunaExportOutcome => ({ + kind: "done", + result: err( + new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { + userMessage: `Kuna's project export is not usable: ${reason}`, + }), + ), +}); + +/** Verify the selected installation at each execution and publication boundary. */ +const verifyKunaExecutable = async ( + installation: KunaInstallation, + signal: AbortSignal, +): Promise> => { + const executable = await hashStableFile(installation.executable, signal); + if (executable.sha256 === installation.sha256) return ok(null); + return err( + new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { + userMessage: `${installation.executable} changed after readiness inspection; reopen the target so REA probes the current Kuna executable.`, + diagnostics: { + expected_sha256: installation.sha256, + observed_sha256: executable.sha256, + }, + }), + ); +}; + +/** + * Inventory and export one target with Kuna, then validate the result. + * + * Kuna runs on a copy hashed while copying, so the export always describes + * the digest the caller selected. Both Kuna runs share one deadline. + */ +export const exportKunaProject = async ( + request: KunaExportRequest, +): Promise => { + const { installation, signal } = request; + const { configuration } = installation; + const deadline = Date.now() + configuration.projectTimeoutMs; + const input = join(request.workspace, "input", basename(request.targetPath)); + await mkdir(join(request.workspace, "input"), { mode: 0o700 }); + await mkdir(request.output, { recursive: true, mode: 0o700 }); + + const copied = await copyAndHash(request.targetPath, input, signal); + if (copied !== request.targetSha256) + return { + kind: "done", + result: err( + new AnalysisArtifactChangedError( + KUNA_EXPORT_OPERATION, + request.targetPath, + "the target no longer matches the digest selected for analysis", + ), + ), + }; + await prepareProcessOwnershipInspection(signal); + const run = (arguments_: readonly string[], diagnosticBytes: number) => + runKuna(request, arguments_, { deadline, diagnosticBytes }); + + request.onStatus({ phase: "inventory", inventoryFunctions: null }); + const listed = await run(["functions", input, "--json"], INVENTORY_BYTES); + if (!listed.ok) return listed.outcome; + const inventory = parseKunaInventory(listed.snapshot.stdout.text); + if (!inventory.ok) return unusable(inventory.error); + if (listed.snapshot.stdout.invalidUtf8 === true) + return unusable("the function inventory is not valid UTF-8"); + await writeFile( + join(request.output, INVENTORY_FILE), + listed.snapshot.stdout.text, + { mode: 0o600 }, + ); + + request.onStatus({ + phase: "decompiling", + inventoryFunctions: inventory.value.length, + }); + const exported = await run( + [ + "decompile-project", + input, + "-o", + request.output, + "--jobs", + String(configuration.jobs), + ], + DIAGNOSTIC_BYTES, + ); + if (!exported.ok) return exported.outcome; + + request.onStatus({ + phase: "sealing", + inventoryFunctions: inventory.value.length, + }); + const summary = parseKunaExportSummary(exported.snapshot.stdout.text); + if (summary === undefined) + return unusable( + "Kuna did not print its `functions: N ok, M failed` summary.", + ); + const stem = basename(request.targetPath); + const names = { + c: `${stem}.c`, + h: `${stem}.h`, + asm: `${stem}.asm`, + readme: "README.md", + inventory: INVENTORY_FILE, + } as const; + const files: Partial> = {}; + for (const [role, name] of Object.entries(names) as [ + keyof KunaExportFiles, + string, + ][]) { + const path = join(request.output, name); + const status = await lstat(path).catch((cause: unknown) => { + if (isMissing(cause)) return undefined; + throw cause; + }); + if (status === undefined || !status.isFile()) + return unusable(`Kuna's export has no regular ${name}.`); + files[role] = { path: name, ...(await hashStableFile(path, signal)) }; + } + const complete = files as KunaExportFiles; + const parsed = await readKunaExport( + request.output, + complete, + summary, + signal, + ); + if (!parsed.ok) return unusable(parsed.error); + const identity = await verifyKunaExecutable(installation, signal); + if (!identity.ok) return { kind: "done", result: identity }; + return { + kind: "done", + result: ok({ files: complete, summary, ...parsed.value }), + }; +}; + +/** Parse an export's function blocks and inventory from its own files. */ +export const readKunaExport = async ( + directory: string, + files: KunaExportFiles, + summary: KunaExportSummary, + signal?: AbortSignal, +): Promise< + Result< + { + readonly functions: readonly KunaProjectFunction[]; + readonly inventory: readonly KunaInventoryFunction[]; + }, + string + > +> => { + const functions = await scanKunaProjectSource( + createReadStream( + join(directory, files.c.path), + signal === undefined ? {} : { signal }, + ), + summary, + ); + if (!functions.ok) return functions; + const inventory = parseKunaInventory( + await readFile(join(directory, files.inventory.path), { + encoding: "utf8", + ...(signal === undefined ? {} : { signal }), + }), + ); + if (!inventory.ok) return inventory; + return ok({ functions: functions.value, inventory: inventory.value }); +}; + +const runKuna = async ( + request: KunaExportRequest, + arguments_: readonly string[], + limits: { readonly deadline: number; readonly diagnosticBytes: number }, +): Promise< + | { readonly ok: true; readonly snapshot: ProviderProcessSnapshot } + | { readonly ok: false; readonly outcome: KunaExportOutcome } +> => { + const { installation, signal } = request; + try { + const identity = await verifyKunaExecutable(installation, signal); + if (!identity.ok) + return { ok: false, outcome: { kind: "done", result: identity } }; + const snapshot = await runOwnedCommand( + { + command: installation.executable, + arguments: arguments_, + runId: `rea-kuna-${randomUUID()}`, + // A script's process reports its interpreter, not the configured path. + ...(installation.script ? { expectedCommand: null } : {}), + cwd: request.workspace, + hostEnvironment: kunaChildEnvironment(installation.configuration), + env: { TMPDIR: request.workspace }, + }, + { + timeoutMs: Math.max(1, limits.deadline - Date.now()), + diagnosticBytes: limits.diagnosticBytes, + }, + { + signal, + ...(request.launcher === undefined + ? {} + : { launcher: request.launcher }), + }, + ); + return { ok: true, snapshot }; + } catch (cause: unknown) { + return { + ok: false, + outcome: commandFailure(cause, request, arguments_[0] ?? "", limits), + }; + } +}; + +/** Copy the target into the workspace and return the SHA-256 of exactly the bytes copied. */ +const copyAndHash = async ( + source: string, + destination: string, + signal: AbortSignal, +): Promise => { + const hash = createHash("sha256"); + await pipeline( + createReadStream(source), + new Transform({ + transform(chunk: Buffer, _encoding, callback) { + hash.update(chunk); + callback(null, chunk); + }, + }), + createWriteStream(destination, { flags: "wx", mode: 0o600 }), + { signal }, + ); + return hash.digest("hex"); +}; + +/** Map an owned-command failure to its typed error, keeping unproven cleanup owned. */ +const commandFailure = ( + cause: unknown, + request: KunaExportRequest, + subcommand: string, + limits: { readonly diagnosticBytes: number }, +): KunaExportOutcome => { + const { installation, signal } = request; + if (!(cause instanceof OwnedCommandFailure)) + return { + kind: "done", + result: err( + signal.aborted + ? new AnalysisCancelledError(KUNA_EXPORT_OPERATION, { cause }) + : new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { + cause, + userMessage: `Cannot run ${installation.executable}: ${message(cause)}`, + }), + ), + }; + const snapshot = cause.snapshot; + const capturedOutput = + snapshot === null + ? undefined + : { + // An inventory is evidence-sized, not diagnostic-sized; keep only its tail. + stdout: snapshot.stdout.text.slice(-4096), + stderr: snapshot.stderr.text, + truncated: + snapshot.diagnosticTruncated === true || + snapshot.outputIncomplete === true || + snapshot.stdout.text.length > 4096, + exit_code: snapshot.exitCode ?? null, + signal: snapshot.signal ?? null, + }; + const details = { + cause, + ...(capturedOutput === undefined ? {} : { capturedOutput }), + }; + if (cause.cleanupFailure !== null) + return { + kind: "retained", + error: new ProviderCleanupError( + KUNA_PROVIDER_ID, + [...cause.resources, request.workspace], + { reason: cause.cleanupFailure }, + { operation: KUNA_EXPORT_OPERATION, cause }, + ), + }; + if (cause.reason === "cancelled") + return { + kind: "done", + result: err(new AnalysisCancelledError(KUNA_EXPORT_OPERATION, details)), + }; + if (cause.reason === "timeout") + return { + kind: "done", + result: err( + new AnalysisTimeoutError( + KUNA_EXPORT_OPERATION, + installation.configuration.projectTimeoutMs, + details, + ), + ), + }; + if (cause.reason === "output-limit") + return { + kind: "done", + result: err( + new AnalysisResourceConstraintError( + KUNA_EXPORT_OPERATION, + "capacity", + `kuna ${subcommand} wrote more than ${String(limits.diagnosticBytes)} bytes of output; the export was not retained.`, + { output_limit_bytes: limits.diagnosticBytes }, + details, + ), + ), + }; + const exitCode = capturedOutput?.exit_code; + return { + kind: "done", + result: err( + new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { + ...details, + userMessage: `kuna ${subcommand} failed${exitCode === undefined || exitCode === null ? "" : ` with exit code ${String(exitCode)}`}: ${capturedOutput?.stderr.trim().split("\n").at(-1) ?? cause.message}`, + }), + ), + }; +}; diff --git a/src/kuna/KunaProjectFiles.ts b/src/kuna/KunaProjectFiles.ts new file mode 100644 index 0000000000..9f0714f58b --- /dev/null +++ b/src/kuna/KunaProjectFiles.ts @@ -0,0 +1,189 @@ +import { z } from "zod"; + +import { err, ok, type Result } from "../domain/result.js"; + +/** + * One function block in a `kuna decompile-project` `.c` file. The span covers + * its `// Function:` header through the blank line that ends the block, which + * is how REA serves one function without reading the whole file. + */ +export interface KunaProjectFunction { + readonly name: string; + /** Kuna's display address, a lowercase `0x…` virtual address. */ + readonly address: string; + readonly cOffset: number; + readonly cLength: number; + /** Kuna's own failure for this function; its block is then the header alone. */ + readonly error: string | null; +} + +// Rendered by kuna-console's `render_c`: `// Function: {name} @ 0x{addr:x}`, +// or `... @ 0x{addr:x} (error: {message})` for a failed function. A name +// can itself contain " @ ", so the address is the last such field. +const HEADER = /^\/\/ Function: (.+) @ (0x[0-9a-f]+)(?: {2}\(error: (.*)\))?$/u; +const HEADER_PREFIX = "// Function: "; + +/** Kuna's completed-export summary on stdout: `functions: N ok, M failed`. */ +const SUMMARY = /functions: (\d+) ok, (\d+) failed/u; + +/** Function counts Kuna reported for a finished export. */ +export interface KunaExportSummary { + readonly decompiled: number; + readonly failed: number; +} + +/** Read the completed-file summary Kuna prints after finalizing an export. */ +export const parseKunaExportSummary = ( + stdout: string, +): KunaExportSummary | undefined => { + const match = SUMMARY.exec(stdout); + if (match?.[1] === undefined || match[2] === undefined) return undefined; + return { decompiled: Number(match[1]), failed: Number(match[2]) }; +}; + +/** A header line longer than this is not one Kuna wrote. */ +const MAX_HEADER_BYTES = 64 * 1024; +const PREFIX_BYTES = Buffer.from(HEADER_PREFIX); + +interface Header { + readonly name: string; + readonly address: string; + readonly error: string | null; + readonly offset: number; +} + +/** + * Split a finished export's `.c` into its function blocks, reading it as a + * stream so a very large export is never held in memory. + * + * The `#include` preamble precedes the first header. The header count must + * equal the functions Kuna reported, and every address must be distinct; an + * export that fails either check is not a usable function inventory. + */ +export const scanKunaProjectSource = async ( + chunks: AsyncIterable, + summary: KunaExportSummary, +): Promise> => { + const headers: Header[] = []; + let offset = 0; + let lineStart = 0; + // Bytes of the current line, kept only while it may still be a header. + let line: Buffer[] | undefined = []; + let lineBytes = 0; + const endLine = (): string | undefined => { + if (line === undefined) return undefined; + const text = Buffer.concat(line).toString("utf8"); + if (!text.startsWith(HEADER_PREFIX)) return undefined; + const match = HEADER.exec(text); + if (match?.[1] === undefined || match[2] === undefined) + return `unrecognized function header at byte ${String(lineStart)}`; + headers.push({ + name: match[1], + address: match[2], + error: match[3] ?? null, + offset: lineStart, + }); + return undefined; + }; + for await (const chunk of chunks) { + let position = 0; + while (position < chunk.length) { + const newline = chunk.indexOf(0x0a, position); + const end = newline === -1 ? chunk.length : newline; + if (line !== undefined) { + const segment = chunk.subarray(position, end); + line.push(segment); + lineBytes += segment.length; + const seen = Buffer.concat(line).subarray(0, PREFIX_BYTES.length); + if (!PREFIX_BYTES.subarray(0, seen.length).equals(seen)) + line = undefined; + else if (lineBytes > MAX_HEADER_BYTES) + return err( + `function header at byte ${String(lineStart)} is too long`, + ); + } + if (newline === -1) break; + const failure = endLine(); + if (failure !== undefined) return err(failure); + position = newline + 1; + lineStart = offset + position; + line = []; + lineBytes = 0; + } + offset += chunk.length; + } + const failure = endLine(); + if (failure !== undefined) return err(failure); + const total = offset; + const expected = summary.decompiled + summary.failed; + if (headers.length !== expected) + return err( + `the .c holds ${String(headers.length)} function headers but Kuna reported ${String(expected)} functions`, + ); + const failed = headers.filter(({ error }) => error !== null).length; + if (failed !== summary.failed) + return err( + `the .c marks ${String(failed)} failed functions but Kuna reported ${String(summary.failed)}`, + ); + const addresses = new Set(); + const functions: KunaProjectFunction[] = []; + for (const [index, header] of headers.entries()) { + if (addresses.has(header.address)) + return err(`the .c repeats address ${header.address}`); + addresses.add(header.address); + const end = headers[index + 1]?.offset ?? total; + functions.push({ + name: header.name, + address: header.address, + cOffset: header.offset, + cLength: end - header.offset, + error: header.error, + }); + } + return ok(functions); +}; + +const inventorySchema = z.object({ + functions: z.array( + z.object({ + name: z.string(), + address_hex: z.string().regex(/^0x[0-9a-f]+$/u), + aliases: z.array(z.string()), + size: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + }), + ), +}); + +/** One callable entry from `kuna functions --json`, including imports Kuna does not decompile. */ +export interface KunaInventoryFunction { + readonly name: string; + readonly address: string; + readonly aliases: readonly string[]; + /** Inventory extent: an upper bound clipped at the next entry, or 0 when unmeasured. */ + readonly size: number; +} + +/** Parse the callable-symbol inventory `kuna functions --json` writes. */ +export const parseKunaInventory = ( + text: string, +): Result => { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return err("the function inventory is not JSON"); + } + const parsed = inventorySchema.safeParse(value); + if (!parsed.success) + return err( + `the function inventory does not match Kuna's document: ${parsed.error.issues[0]?.message ?? "invalid"}`, + ); + return ok( + parsed.data.functions.map((entry) => ({ + name: entry.name, + address: entry.address_hex, + aliases: entry.aliases, + size: entry.size, + })), + ); +}; diff --git a/src/kuna/KunaRelease.ts b/src/kuna/KunaRelease.ts new file mode 100644 index 0000000000..e1ff9b8311 --- /dev/null +++ b/src/kuna/KunaRelease.ts @@ -0,0 +1,16 @@ +/** Kuna release whose CLI and project layout REA verified; others report as unverified. */ +export const KUNA_VERIFIED_VERSION = "1.833"; + +/** Stable provider ID shared by readiness, doctor and Evidence. */ +export const KUNA_PROVIDER_ID = "kuna"; + +/** Remediation when `REA_KUNA_COMMAND` is missing or not an absolute path. */ +export const KUNA_COMMAND_REMEDIATION = `Set REA_KUNA_COMMAND to the absolute path of a caller-supplied kuna executable (verified with ${KUNA_VERIFIED_VERSION}). REA does not download, install or update Kuna.`; + +/** Remediation when the host cannot supervise a Kuna process tree. */ +export const KUNA_UNSUPPORTED_HOST_REMEDIATION = + "Use Linux or macOS for the Kuna provider. Windows process-tree ownership for Kuna is not verified."; + +/** Remediation when `kuna --version` cannot establish the installed release. */ +export const KUNA_VERSION_PROBE_REMEDIATION = + "Run the selected executable with --version: it must exit promptly and print `kuna .`."; diff --git a/tests/boundary/providers/kuna/fakeKuna.ts b/tests/boundary/providers/kuna/fakeKuna.ts new file mode 100644 index 0000000000..24c04b1a95 --- /dev/null +++ b/tests/boundary/providers/kuna/fakeKuna.ts @@ -0,0 +1,125 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** One function the fake exports; `error` renders Kuna's failed-function header. */ +export interface FakeKunaFunction { + readonly name: string; + readonly addr: string; + readonly error?: string; +} + +/** Scripted behavior of the fake `kuna` executable for one test. */ +export interface FakeKunaBehavior { + /** Text printed by `--version`; `exit` fails the probe instead. */ + readonly version?: + | { readonly stdout: string } + | { readonly exit: number } + | { readonly ignoreTerm: true }; + readonly inventory?: "ok" | "not-json" | "hang"; + readonly project?: + | "ok" + | "exit-failure" + | "hang" + | "miscounted" + | "silent" + | "block-manifest"; + readonly functions?: readonly FakeKunaFunction[]; + /** Extra `.asm` bytes, for capacity tests. */ + readonly asmPadding?: number; + /** Milliseconds the export takes before writing its files. */ + readonly delayMs?: number; +} + +/** + * The fake reads its behavior from `