From 5b983516ab5cb232d06488fcfe92d44d8092ee01 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:38:53 +0800 Subject: [PATCH] fix(hopper): consolidate release metadata and archive identity ownership Share release JSON decoding, vendor fields and package integrity checks across macOS setup and Linux publishing/cache reads. Reject malformed UTF-8 instead of repairing macOS metadata, retain established initial BOM tolerance, and preserve complete vendor rows through admission and cache retention. Keep platform channel, signature, installation and cleanup policies unchanged. Validation: 134 focused tests passed with 21 platform skips; typecheck and lint passed. Linux-only signed-cache cases were updated but skipped on the macOS host. No live Hopper installation performed. --- src/application/HopperReleaseFeed.test.ts | 44 ++++++- src/application/HopperReleaseFeed.ts | 120 ++++++++++++++---- src/application/LinuxHopperPublisher.ts | 24 ++-- src/application/MacHopper.test.ts | 78 +++++++++--- src/application/MacHopper.ts | 47 +++---- .../linuxHopperPublisherVerification.test.ts | 93 ++++++++++++++ 6 files changed, 315 insertions(+), 91 deletions(-) diff --git a/src/application/HopperReleaseFeed.test.ts b/src/application/HopperReleaseFeed.test.ts index 7e78f83fbd..663e6304c1 100644 --- a/src/application/HopperReleaseFeed.test.ts +++ b/src/application/HopperReleaseFeed.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { parseLinuxHopperReleases } from "./HopperReleaseFeed.js"; +import { + parseLinuxHopperReleases, + parseMacHopperReleases, + parseHopperReleaseJson, +} from "./HopperReleaseFeed.js"; const release = { version: "99.2.1", @@ -79,3 +83,41 @@ describe("Linux Hopper release feed boundary", () => { }, ); }); + +it("retains complete vendor release rows including own special keys and nested observations", () => { + const row: unknown = JSON.parse( + JSON.stringify(release).replace( + /\}$/u, + ',"__proto__":{"observed":true},"constructor":"vendor constructor","nested":[{"__proto__":"nested fact","future":null}]}', + ), + ); + const parsed = parseLinuxHopperReleases({ "Ubuntu / Mint": row }); + if (!parsed.ok || !parsed.value.deb.ok) throw new Error("Missing release"); + expect(JSON.stringify(parsed.value.deb.value)).toBe(JSON.stringify(row)); + expect(Object.hasOwn(parsed.value.deb.value, "__proto__")).toBe(true); + expect(Object.hasOwn(parsed.value.deb.value, "constructor")).toBe(true); +}); + +it("retains the full selected macOS release row", () => { + const row: unknown = JSON.parse( + '{"filename":"https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg","file_length":"123","file_hash":"' + + "a".repeat(40) + + '","__proto__":{"observed":true},"constructor":"vendor constructor","nested":[{"__proto__":"nested fact"}]}', + ); + const parsed = parseMacHopperReleases({ "OS X": row }); + if (!parsed.ok) throw new Error("Missing release"); + expect(JSON.stringify(parsed.value)).toBe(JSON.stringify(row)); + expect(Object.hasOwn(parsed.value, "__proto__")).toBe(true); +}); + +it("distinguishes malformed UTF-8 from a genuine replacement character and tolerates the initial BOM", () => { + expect( + parseHopperReleaseJson( + new Uint8Array([0x7b, 0x22, 0x78, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d]), + ).ok, + ).toBe(false); + for (const bom of ["", "\uFEFF"]) + expect( + parseHopperReleaseJson(new TextEncoder().encode(bom + '{"x":"雪�"}')), + ).toMatchObject({ ok: true, value: { x: "雪�" } }); +}); diff --git a/src/application/HopperReleaseFeed.ts b/src/application/HopperReleaseFeed.ts index 1a6c3d3344..b11e400c3e 100644 --- a/src/application/HopperReleaseFeed.ts +++ b/src/application/HopperReleaseFeed.ts @@ -1,5 +1,11 @@ +import { createHash } from "node:crypto"; import { z } from "zod"; +import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; +import { + safeParseUtf8Json, + type SafeJsonParseResult, +} from "../domain/safeJson.js"; import { err, ok, type Result } from "../domain/result.js"; import { HOPPER_DOWNLOAD_BYTES } from "./HopperDownload.js"; import { urlDeclaresQueryOrFragment } from "../domain/urlComponents.js"; @@ -12,6 +18,66 @@ export const HOPPER_PUBLIC_KEY_URL = export const HOPPER_DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; +/** Decode vendor/cache JSON while retaining their established initial UTF-8 BOM tolerance. */ +export const parseHopperReleaseJson = ( + bytes: Uint8Array, +): SafeJsonParseResult => + safeParseUtf8Json( + bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf + ? bytes.subarray(3) + : bytes, + ); + +/** Package identity is shared by the platform-specific publisher verification workflows. */ +export const hopperArchiveIntegrityMatches = ( + bytes: Uint8Array, + release: Readonly<{ file_length: string; file_hash: string }>, +): boolean => + bytes.byteLength === Number(release.file_length) && + bytes.byteLength <= HOPPER_DOWNLOAD_BYTES && + createHash("sha1").update(bytes).digest("hex") === release.file_hash; + +const archiveReleaseSchema = z.object({ + filename: z.string().url(), + file_length: z.string().regex(/^\d+$/u), + file_hash: z.string().regex(/^[a-f0-9]{40}$/u), +}); +export type MacHopperRelease = z.infer & + Record; + +const selectedPlatformsSchema = z.object({ + "OS X": z.unknown().optional(), + "Ubuntu / Mint": z.unknown().optional(), + Fedora: z.unknown().optional(), + Arch: z.unknown().optional(), +}); + +/** Admit the selected macOS row without projecting away vendor release metadata. */ +export const parseMacHopperReleases = ( + raw: unknown, +): Result => { + const root = selectedPlatformsSchema.safeParse(raw); + const release = jsonObjectSchema.safeParse( + root.success ? root.data["OS X"] : undefined, + ); + if (!release.success || !archiveReleaseSchema.safeParse(release.data).success) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper macOS release metadata is malformed.", + ), + ); + const retained = release.data as MacHopperRelease; + if (!retained.filename.startsWith(HOPPER_DOWNLOAD_PREFIX)) + return err( + new HopperReleaseError( + "release_metadata", + "The Hopper macOS release is outside the official download channel.", + ), + ); + return ok(retained); +}; + /** Stable publisher identity; signing subkeys are admitted through this primary. */ export const HOPPER_LINUX_PRIMARY_FINGERPRINT = "7C2D73BAFAF5B9A710A69DA1A26DF815099B1BCD"; @@ -46,32 +112,27 @@ export class HopperReleaseError extends Error { } 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(); +const releaseSchema = archiveReleaseSchema.extend({ + version: z.string().min(1), + file_length: z + .string() + .regex(/^[1-9]\d*$/u) + .refine((text) => { + const count = Number(text); + return Number.isSafeInteger(count) && count <= HOPPER_DOWNLOAD_BYTES; + }), + 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"), +}); /** Parsed once at the feed boundary; preserve the vendor's complete release row. */ -export type LinuxHopperRelease = z.infer; +export type LinuxHopperRelease = z.infer & + Record; export type LinuxHopperReleaseFeed = Readonly< Record> >; @@ -80,8 +141,11 @@ export const parseLinuxHopperRelease = ( raw: unknown, family: LinuxPackageFamily, ): Result => { - const parsed = releaseSchema.safeParse(raw); - if (!parsed.success) + const retained = jsonObjectSchema.safeParse(raw); + const parsed = releaseSchema.safeParse( + retained.success ? retained.data : undefined, + ); + if (!retained.success || !parsed.success) return err( new HopperReleaseError( "release_metadata", @@ -103,13 +167,13 @@ export const parseLinuxHopperRelease = ( "The Hopper release is outside the official public Linux demo channel.", ), ); - return ok(parsed.data); + return ok(retained.data as LinuxHopperRelease); }; export const parseLinuxHopperReleases = ( raw: unknown, ): Result => { - const feed = z.record(z.string(), z.unknown()).safeParse(raw); + const feed = selectedPlatformsSchema.safeParse(raw); if (!feed.success) return err( new HopperReleaseError( diff --git a/src/application/LinuxHopperPublisher.ts b/src/application/LinuxHopperPublisher.ts index d4525ee273..b16e03d83c 100644 --- a/src/application/LinuxHopperPublisher.ts +++ b/src/application/LinuxHopperPublisher.ts @@ -40,6 +40,8 @@ import { HOPPER_PUBLIC_KEY_URL, HOPPER_RELEASES_URL, HopperReleaseError, + hopperArchiveIntegrityMatches, + parseHopperReleaseJson, parseLinuxHopperRelease, parseLinuxHopperReleases, type LinuxHopperRelease, @@ -126,9 +128,9 @@ export class LinuxHopperPublisher { "release_metadata", "The official Hopper release feed is unavailable.", ); - this.#feed = parseLinuxHopperReleases( - JSON.parse(decodeUtf8(response.bytes)), - ); + const decoded = parseHopperReleaseJson(response.bytes); + if (!decoded.ok) throw decoded.cause; + this.#feed = parseLinuxHopperReleases(decoded.value); } if (!this.#feed.ok) return this.#feed; return this.#feed.value[family]; @@ -171,10 +173,7 @@ export class LinuxHopperPublisher { signal, resources, ); - if ( - bytes.length !== Number(release.file_length) || - createHash("sha1").update(bytes).digest("hex") !== release.file_hash - ) + if (!hopperArchiveIntegrityMatches(bytes, release)) return err( new HopperReleaseError( "integrity", @@ -730,17 +729,14 @@ export class LinuxHopperPublisher { signal, resources, ); - let value: unknown; - try { - value = JSON.parse(decodeUtf8(metadata)); - } catch (cause: unknown) { + const decoded = parseHopperReleaseJson(metadata); + if (!decoded.ok) throw new HopperReleaseError( "release_metadata", "The retained Hopper package metadata is malformed.", - { cause }, + { cause: decoded.cause }, ); - } - const parsed = cacheRecordSchema.safeParse(value); + const parsed = cacheRecordSchema.safeParse(decoded.value); if (!parsed.success) throw new HopperReleaseError( "release_metadata", diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts index 2eef63ed87..a9606020f5 100644 --- a/src/application/MacHopper.test.ts +++ b/src/application/MacHopper.test.ts @@ -12,6 +12,9 @@ import { class FakeMacHopperHost implements MacHopperInstallHost { readonly archive = new TextEncoder().encode("hopper-dmg"); archiveOk = true; + feedBytes: Uint8Array | undefined; + packageDownloads = 0; + mounts = 0; verification: MacHopperBundleVerification = "verified"; existing = false; mountSucceeds = true; @@ -25,33 +28,39 @@ class FakeMacHopperHost implements MacHopperInstallHost { opens = 0; replaces = false; - download = (url: string) => - Promise.resolve( + download = (url: string) => { + if (!url.includes("files-api")) this.packageDownloads += 1; + return Promise.resolve( url.includes("files-api") ? { ok: true, - bytes: new TextEncoder().encode( - JSON.stringify({ - "OS X": { - filename: - "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg", - file_length: String(this.archive.byteLength), - file_hash: this.badHash - ? "0".repeat(40) - : createHash("sha1").update(this.archive).digest("hex"), - }, - }), - ), + bytes: + this.feedBytes ?? + new TextEncoder().encode( + JSON.stringify({ + "OS X": { + filename: + "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg", + file_length: String(this.archive.byteLength), + file_hash: this.badHash + ? "0".repeat(40) + : createHash("sha1").update(this.archive).digest("hex"), + }, + }), + ), } : { ok: this.archiveOk, bytes: this.archive }, ); + }; createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test"); createMountDirectory = () => Promise.resolve(); writePackage = () => Promise.resolve(); - mount = () => - this.mountFailure === undefined + mount = () => { + this.mounts += 1; + return this.mountFailure === undefined ? Promise.resolve(this.mountSucceeds) : Promise.reject(this.mountFailure); + }; unmount = () => { this.unmounts += 1; return Promise.resolve(); @@ -261,3 +270,40 @@ it("retains the exact DMG temporary root with its retry capability", async () => allowCleanup = true; await expect(retained[0]!.close()).resolves.toBeUndefined(); }); + +it("refuses malformed UTF-8 release metadata before downloading or mounting the package", async () => { + const host = new FakeMacHopperHost(); + const feed = await host.download("files-api"); + const text = new TextDecoder().decode(feed.bytes); + // Replacement decoding would produce valid JSON and hide the malformed transport byte. + host.feedBytes = new Uint8Array([ + ...new TextEncoder().encode('{"vendor_note":"'), + 0xff, + ...new TextEncoder().encode('",' + text.slice(1)), + ]); + expect(await installMacHopper({}, host)).toMatchObject({ + status: "failed", + reason: "release_metadata", + }); + expect(host.packageDownloads).toBe(0); + expect(host.mounts).toBe(0); + expect(host.installs).toBe(0); +}); + +it.each([false, true])( + "accepts a genuine replacement character in release metadata (BOM=%s)", + async (bom) => { + const host = new FakeMacHopperHost(); + const feed = await host.download("files-api"); + const text = new TextDecoder() + .decode(feed.bytes) + .replace('{"OS X":{', '{"OS X":{"vendor_note":"雪�",'); + host.feedBytes = new TextEncoder().encode((bom ? "\uFEFF" : "") + text); + expect(await installMacHopper({}, host)).toMatchObject({ + status: "installed", + }); + expect(host.packageDownloads).toBe(1); + expect(host.mounts).toBe(1); + expect(host.installs).toBe(1); + }, +); diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index a38276aaaf..c9f90475ef 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -1,4 +1,3 @@ -import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { access, @@ -13,22 +12,22 @@ import { tmpdir } from "node:os"; import { join, posix } from "node:path"; import { promisify } from "node:util"; -import { z } from "zod"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import type { AnalysisCleanupObservation } from "../domain/analysisErrorBase.js"; import { HOPPER_PUBLISHER } from "../domain/hopperPublisher.js"; import { macDeveloperIdRequirement } from "../process/MacCodeSignature.js"; import { macHopperUserApplicationPath } from "./MacHopperPaths.js"; +import { downloadHopperPackage } from "./HopperDownload.js"; import { - downloadHopperPackage, - HOPPER_DOWNLOAD_BYTES, -} from "./HopperDownload.js"; + HOPPER_RELEASES_URL, + HOPPER_DOWNLOAD_PREFIX, + parseHopperReleaseJson, + parseMacHopperReleases, + hopperArchiveIntegrityMatches, +} from "./HopperReleaseFeed.js"; import type { HopperInstallFailureReason } from "./SetupInstallFailure.js"; const execFileAsync = promisify(execFile); -const RELEASES_URL = - "https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; -const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; const HOPPER_PUBLISHER_REQUIREMENT = macDeveloperIdRequirement( HOPPER_PUBLISHER.bundleIdentifier, HOPPER_PUBLISHER.teamIdentifier, @@ -75,7 +74,7 @@ export interface MacHopperBundleInstallOperations { /** Stable network and command effects disclosed before macOS Hopper setup. */ export const macHopperInstallDisclosure = { - networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const, + networkOrigins: [HOPPER_RELEASES_URL, HOPPER_DOWNLOAD_PREFIX] as const, commands: [ "hdiutil attach -readonly -nobrowse -mountpoint ", "codesign --verify --deep --strict ", @@ -143,19 +142,18 @@ export const installMacHopper = async ( let result: MacHopperInstallResult; try { result = await (async (): Promise => { - const metadata = await host.download(RELEASES_URL); + const metadata = await host.download(HOPPER_RELEASES_URL); if (!metadata.ok) return { status: "failed", reason: "release_metadata" }; - const parsed = releasesSchema.safeParse(parseJson(metadata.bytes)); - if (!parsed.success) - return { status: "failed", reason: "release_metadata" }; - const release = parsed.data["OS X"]; - if (!release.filename.startsWith(DOWNLOAD_PREFIX)) - return { status: "failed", reason: "release_metadata" }; + const decoded = parseHopperReleaseJson(metadata.bytes); + if (!decoded.ok) throw decoded.cause; + const parsed = parseMacHopperReleases(decoded.value); + if (!parsed.ok) return { status: "failed", reason: "release_metadata" }; + const release = parsed.value; installStage = "download"; const archive = await host.download(release.filename); if (!archive.ok) return { status: "failed", reason: "download" }; installStage = "integrity"; - if (!integrityMatches(archive.bytes, release)) + if (!hopperArchiveIntegrityMatches(archive.bytes, release)) return { status: "failed", reason: "integrity" }; installStage = "destination_inspection"; @@ -471,15 +469,6 @@ export const systemMacHopperInstallHost = ( }; }; -const parseJson = (bytes: Uint8Array): unknown => - JSON.parse(new TextDecoder().decode(bytes)); -const integrityMatches = ( - bytes: Uint8Array, - release: z.infer, -): boolean => - bytes.byteLength === Number(release.file_length) && - bytes.byteLength <= HOPPER_DOWNLOAD_BYTES && - createHash("sha1").update(bytes).digest("hex") === release.file_hash; const commandSucceeds = async ( command: string, args: readonly string[], @@ -524,9 +513,3 @@ const pathExists = async (path: string): Promise => { throw cause; } }; -const releaseSchema = z.object({ - filename: z.string().url(), - file_length: z.string().regex(/^\d+$/u), - file_hash: z.string().regex(/^[a-f0-9]{40}$/u), -}); -const releasesSchema = z.object({ "OS X": releaseSchema }); diff --git a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts index 1f95aad8b0..f1a72d7b69 100644 --- a/tests/boundary/setup/linuxHopperPublisherVerification.test.ts +++ b/tests/boundary/setup/linuxHopperPublisherVerification.test.ts @@ -30,6 +30,7 @@ import { import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { HOPPER_LINUX_PRIMARY_FINGERPRINT, + HOPPER_RELEASES_URL, type LinuxHopperRelease, } from "../../../src/application/HopperReleaseFeed.js"; import * as signatures from "../../../src/process/DetachedPgpSignature.js"; @@ -152,6 +153,82 @@ it.each([undefined, "", "/selected/cache", "relative/cache"])( }, ); +const feedRelease = () => ({ + version: "1.0", + filename: + "https://www.hopperapp.com:443/downloader/public/Hopper-1.0-Linux-demo.deb", + file_length: "1", + file_hash: "a".repeat(40), + main_exe_hash: "b".repeat(64), + signature: "AQ==", + public: "1", + beta: "0", + vendor_label: "valid � label", + ...JSON.parse( + '{"__proto__":{"constructor":"retained"},"vendor_details":{"__proto__":"nested"}}', + ), +}); + +it.each([false, true])( + "retains fetched vendor release JSON with initial BOM=%s", + async (bom) => { + const root = await createTestTempDirectory("rea-linux-feed-json-"); + const publisher = new LinuxHopperPublisher( + { XDG_CACHE_HOME: root }, + async () => "deb", + ); + const release = feedRelease(); + const text = JSON.stringify({ "Ubuntu / Mint": release }); + const network = vi.fn( + async () => new Response(`${bom ? "\uFEFF" : ""}${text}`), + ); + vi.stubGlobal("fetch", network); + try { + const selected = await publisher.currentRelease("deb"); + expect(selected).toEqual({ ok: true, value: release }); + if (!selected.ok) throw selected.error; + expect(Object.hasOwn(selected.value, "__proto__")).toBe(true); + expect(selected.value.vendor_label).toBe("valid � label"); + expect(await publisher.currentRelease("deb")).toEqual(selected); + expect(network).toHaveBeenCalledOnce(); + expect(network).toHaveBeenCalledWith( + HOPPER_RELEASES_URL, + expect.any(Object), + ); + } finally { + await publisher.close(); + } + }, +); + +it("rejects malformed UTF-8 fetched release metadata without repairing a vendor value", async () => { + const root = await createTestTempDirectory("rea-linux-feed-bytes-"); + const publisher = new LinuxHopperPublisher( + { XDG_CACHE_HOME: root }, + async () => "deb", + ); + const bytes = Buffer.from(JSON.stringify({ "Ubuntu / Mint": feedRelease() })); + const offset = bytes.indexOf(Buffer.from("�")); + expect(offset).toBeGreaterThanOrEqual(0); + const invalid = Buffer.concat([ + bytes.subarray(0, offset), + Buffer.from([0xff]), + bytes.subarray(offset + 3), + ]); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(Uint8Array.from(invalid).buffer)), + ); + try { + expect(await publisher.currentRelease("deb")).toMatchObject({ + ok: false, + error: { reason: "release_metadata" }, + }); + } finally { + await publisher.close(); + } +}); + describe("Linux signed package retention boundary", () => { linux( "rejects a cryptographically valid package signed by another publisher", @@ -183,6 +260,14 @@ describe("Linux signed package retention boundary", () => { "authenticates a real %s payload and verifies a retained install without network", async (family) => { const f = await fixture(family); + const vendor = { + label: "valid � publisher", + nested: { ["__proto__"]: "retained" }, + }; + Object.defineProperty(f.release, "__proto__", { + value: vendor, + enumerable: true, + }); admitFixturePublisher(f.fingerprint); const network = vi.fn(() => { throw new Error("Offline"); @@ -193,6 +278,11 @@ describe("Linux signed package retention boundary", () => { expect( await f.publisher.retainVerifiedArchive(verifiedArchive, f.archive), ).toEqual({ ok: true, value: undefined }); + const cached = JSON.parse( + await readFile(join(f.cache, "release.json"), "utf8"), + ); + expect(Object.hasOwn(cached.release, "__proto__")).toBe(true); + expect(cached.release["__proto__"]).toEqual(vendor); 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. @@ -218,6 +308,9 @@ describe("Linux signed package retention boundary", () => { source: "signed_package_cache", }, }); + expect( + JSON.parse(await readFile(join(f.cache, "release.json"), "utf8")), + ).toEqual(cached); expect(network).not.toHaveBeenCalled(); expect(signature).toHaveBeenCalledOnce(); expect(extraction).toHaveBeenCalledWith(