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.
This commit is contained in:
morluto committed 2026-10-26 22:38:53 +08:00
1 parent 19447516a4
commit 5b983516ab
6 files changed
+315 -91

No files matched your search

+43 -1
View File
@@ -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: "雪�" } });
});
+92 -28
View File
@@ -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<typeof archiveReleaseSchema> &
Record<string, JsonValue>;
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<MacHopperRelease, HopperReleaseError> => {
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<typeof releaseSchema>;
export type LinuxHopperRelease = z.infer<typeof releaseSchema> &
Record<string, JsonValue>;
export type LinuxHopperReleaseFeed = Readonly<
Record<LinuxPackageFamily, Result<LinuxHopperRelease, HopperReleaseError>>
>;
@@ -80,8 +141,11 @@ export const parseLinuxHopperRelease = (
raw: unknown,
family: LinuxPackageFamily,
): Result<LinuxHopperRelease, HopperReleaseError> => {
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<LinuxHopperReleaseFeed, HopperReleaseError> => {
const feed = z.record(z.string(), z.unknown()).safeParse(raw);
const feed = selectedPlatformsSchema.safeParse(raw);
if (!feed.success)
return err(
new HopperReleaseError(
+10 -14
View File
@@ -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",
+62 -16
View File
@@ -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);
},
);
+15 -32
View File
@@ -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 <private-mount> <verified-hopper.dmg>",
"codesign --verify --deep --strict <mounted-hopper.app>",
@@ -143,19 +142,18 @@ export const installMacHopper = async (
let result: MacHopperInstallResult;
try {
result = await (async (): Promise<MacHopperInstallResult> => {
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<typeof releaseSchema>,
): 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<boolean> => {
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 });
@@ -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(