mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
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:
1 parent
19447516a4
commit
5b983516ab
6 files changed
+315
-91
No files matched your search
@@ -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: "雪�" } });
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user