Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/shell/src/main/apps/first-party.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
firstPartyAppById,
firstPartyAppsDir,
readFirstPartyCatalog,
resolveFirstPartyAppsDir,
} from "./first-party";

describe("BOOTSTRAP_APPS", () => {
Expand All @@ -26,6 +27,26 @@ describe("firstPartyAppsDir", () => {
});
});

describe("resolveFirstPartyAppsDir", () => {
it("uses the dev relative walk when not packaged", () => {
expect(
resolveFirstPartyAppsDir("/r/packages/shell/out/main", {
isPackaged: false,
resourcesPath: "/unused",
}),
).toBe("/r/apps");
});

it("uses the extraResources tree when packaged — the asar-relative walk does not exist there", () => {
expect(
resolveFirstPartyAppsDir("/Applications/Brainstorm.app/Contents/Resources/app.asar/out/main", {
isPackaged: true,
resourcesPath: "/Applications/Brainstorm.app/Contents/Resources",
}),
).toBe("/Applications/Brainstorm.app/Contents/Resources/apps");
});
});

describe("firstPartyAppById", () => {
it("resolves a known id and returns undefined for an unknown one", () => {
expect(firstPartyAppById("io.brainstorm.notes")?.dir).toBe("notes");
Expand Down
15 changes: 15 additions & 0 deletions packages/shell/src/main/apps/first-party.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ export function firstPartyAppsDir(mainDir: string): string {
return join(mainDir, "..", "..", "..", "..", "apps");
}

/**
* Resolve the first-party bundle tree for the *running* build. In dev the
* repo's `apps/` sits four levels above the compiled main entry; in a
* packaged shell that walk lands inside (nonexistent next to) the asar, so
* the correct root is `process.resourcesPath/apps` — the same tree
* `bootstrapApps` installs from (electron-builder `extraResources`). Pure
* (packaging facts injected) so the branch is unit-testable.
*/
export function resolveFirstPartyAppsDir(
mainDir: string,
runtime: { isPackaged: boolean; resourcesPath: string },
): string {
return runtime.isPackaged ? join(runtime.resourcesPath, "apps") : firstPartyAppsDir(mainDir);
}

export function firstPartyAppById(appId: string): FirstPartyApp | undefined {
return FIRST_PARTY_APPS.find((app) => app.expectedAppId === appId);
}
Expand Down
35 changes: 28 additions & 7 deletions packages/shell/src/main/bundle/bundle-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const CODE_COMPRESSION: Record<number, BundleCompression> = {

type ZstdCapableZlib = typeof zlib & {
zstdCompressSync: (buf: Buffer) => Buffer;
zstdDecompressSync: (buf: Buffer) => Buffer;
zstdDecompressSync: (buf: Buffer, options?: { maxOutputLength?: number }) => Buffer;
};

function zstdCapable(): ZstdCapableZlib | null {
Expand Down Expand Up @@ -69,16 +69,25 @@ function compress(algo: BundleCompression, bytes: Buffer): Buffer {
}
}

function decompress(algo: BundleCompression, bytes: Buffer): Buffer {
function decompress(algo: BundleCompression, bytes: Buffer, maxOutputBytes?: number): Buffer {
switch (algo) {
case BundleCompression.None:
if (maxOutputBytes !== undefined && bytes.length > maxOutputBytes) {
throw new Error(`bundle: content exceeds the ${maxOutputBytes}-byte unpack limit`);
}
return bytes;
case BundleCompression.Gzip:
return zlib.gunzipSync(bytes);
return zlib.gunzipSync(
bytes,
maxOutputBytes !== undefined ? { maxOutputLength: maxOutputBytes } : {},
);
case BundleCompression.Zstd: {
const z = zstdCapable();
if (!z) throw new Error("bundle: zstd bundle cannot be read in this runtime");
return z.zstdDecompressSync(bytes);
return z.zstdDecompressSync(
bytes,
maxOutputBytes !== undefined ? { maxOutputLength: maxOutputBytes } : {},
);
}
}
}
Expand All @@ -101,9 +110,21 @@ export function packBundle(
return Buffer.concat([head, payload]);
}

export type UnpackBundleOptions = {
/** Cap on the decompressed tar size. A hostile archive can compress a huge
* payload into a tiny file (decompression bomb) — untrusted-input callers
* (the sideload install path) MUST bound the expansion. Throws when the
* content would exceed the cap. */
maxOutputBytes?: number;
};

/** Unpack a `.bsbundle` byte buffer into a path→bytes map. Throws on a bad
* magic / unknown container version / unknown compression code. */
export function unpackBundle(bundle: Uint8Array): Map<string, Uint8Array> {
* magic / unknown container version / unknown compression code, or when the
* decompressed content exceeds `maxOutputBytes`. */
export function unpackBundle(
bundle: Uint8Array,
options: UnpackBundleOptions = {},
): Map<string, Uint8Array> {
const buf = Buffer.from(bundle);
if (
buf.length < MAGIC_BYTES.length + 2 ||
Expand All @@ -120,7 +141,7 @@ export function unpackBundle(bundle: Uint8Array): Map<string, Uint8Array> {
if (algo === undefined) {
throw new Error(`bundle: unknown compression code ${code}`);
}
const tar = decompress(algo, buf.subarray(MAGIC_BYTES.length + 2));
const tar = decompress(algo, buf.subarray(MAGIC_BYTES.length + 2), options.maxOutputBytes);
const out = new Map<string, Uint8Array>();
for (const entry of unpackTar(tar)) out.set(entry.path, entry.data);
return out;
Expand Down
10 changes: 10 additions & 0 deletions packages/shell/src/main/catalog/brainstorm-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ describe("brainstorm-package — pack/unpack", () => {
expect(Buffer.from(bytes.subarray(0, 4)).toString("ascii")).toBe("BSB1");
expect(bytes[5]).toBe(1);
});

it("bounds decompression (bomb defense): a tiny archive expanding past maxOutputBytes throws", () => {
// 256 KiB of zeros gzips to a few hundred bytes.
const files = new Map<string, Uint8Array>([["big.bin", new Uint8Array(256 * 1024)]]);
const bytes = packBrainstormBundle(files);
expect(bytes.length).toBeLessThan(4096);
expect(() => unpackBrainstormBundle(bytes, { maxOutputBytes: 4096 })).toThrow();
// Within the bound it still unpacks.
expect(unpackBrainstormBundle(bytes, { maxOutputBytes: 1024 * 1024 }).size).toBe(1);
});
});

describe("brainstorm-package — unpack to dir", () => {
Expand Down
16 changes: 11 additions & 5 deletions packages/shell/src/main/catalog/brainstorm-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join, resolve, sep } from "node:path";
import { ed25519Sign, ed25519Verify } from "@brainstorm-os/native";
import { packBundle, unpackBundle } from "../bundle/bundle-archive";
import { type UnpackBundleOptions, packBundle, unpackBundle } from "../bundle/bundle-archive";
import { BundleCompression } from "../bundle/bundle-format";

/** The `ed25519:` prefix on a wire publisher key (`ed25519:<base64url 32-byte>`). */
Expand All @@ -37,9 +37,14 @@ export function packBrainstormBundle(files: ReadonlyMap<string, Uint8Array>): Ui
}

/** Unpack a `.brainstorm` archive to a path → bytes map. Throws on bad magic /
* version / compression (the codec) or an unsafe path (the tar zip-slip guard). */
export function unpackBrainstormBundle(bytes: Uint8Array): Map<string, Uint8Array> {
return unpackBundle(bytes);
* version / compression (the codec), an unsafe path (the tar zip-slip guard),
* or content exceeding `options.maxOutputBytes` (decompression-bomb bound —
* untrusted-input callers must set it). */
export function unpackBrainstormBundle(
bytes: Uint8Array,
options: UnpackBundleOptions = {},
): Map<string, Uint8Array> {
return unpackBundle(bytes, options);
}

/**
Expand All @@ -51,9 +56,10 @@ export function unpackBrainstormBundle(bytes: Uint8Array): Map<string, Uint8Arra
export async function unpackBrainstormBundleToDir(
bytes: Uint8Array,
destDir: string,
options: UnpackBundleOptions = {},
): Promise<string> {
const root = resolve(destDir);
const files = unpackBrainstormBundle(bytes);
const files = unpackBrainstormBundle(bytes, options);
for (const [path, data] of files) {
const target = resolve(root, path);
if (target !== root && !target.startsWith(root + sep)) {
Expand Down
91 changes: 89 additions & 2 deletions packages/shell/src/main/dev/seed-demo-apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,34 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CapabilityLedger } from "@brainstorm-os/capabilities/ledger";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const spawnSpy = vi.fn(() => {
throw new Error("spawn must not be called in prebuiltOnly mode");
});
vi.mock("node:child_process", () => ({
spawn: (...args: unknown[]) => spawnSpy(...(args as [])),
}));

let activeSession: unknown;
vi.mock("../vault/session", () => ({
getActiveVaultSession: () => activeSession,
}));

import type { FirstPartyApp } from "../apps/first-party";
import { InstallOrigin } from "../apps/install-provenance";
import { AppInstaller } from "../apps/installer";
import type { AppManifest } from "../apps/manifest";
import { validateManifest } from "../apps/manifest";
import type { DashboardStore } from "../dashboard/dashboard-store";
import { DataStores } from "../storage/data-stores";
import { AppsRepository } from "../storage/registry-repo/apps-repo";
import { FIRST_PARTY_APPS, installPrebuiltBundle, summarizeBuildStderr } from "./seed-demo-apps";
import {
FIRST_PARTY_APPS,
installPrebuiltBundle,
reinstallFirstPartyApp,
summarizeBuildStderr,
} from "./seed-demo-apps";

const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..");

Expand Down Expand Up @@ -89,6 +108,7 @@ function makeFakeDashboard(): DashboardStore {
icons[id] = record;
},
isAppIconDismissed: () => false,
clearAppIconDismissed: () => {},
};
return shim as unknown as DashboardStore;
}
Expand Down Expand Up @@ -190,6 +210,73 @@ describe("installPrebuiltBundle", () => {
});
});

/**
* AppForge-1 packaged-path fix — `reinstallFirstPartyApp` in a packaged shell
* (`prebuiltOnly`) installs the extraResources bundle as-is with
* `bootstrap-cache` provenance and NEVER spawns `vite build`: a packaged
* build has no sources, no bun on PATH, and a read-only resources tree.
* The spawn spy at the top of this file throws if any code path reaches it.
*/
describe("reinstallFirstPartyApp — prebuiltOnly (packaged shell)", () => {
const NOTES_ID = "io.brainstorm.notes";
let vaultDir: string;
let appsDir: string;
let stores: DataStores;

beforeEach(async () => {
spawnSpy.mockClear();
vaultDir = await mkdtemp(join(tmpdir(), "bs-reinstall-vault-"));
appsDir = await mkdtemp(join(tmpdir(), "bs-reinstall-apps-"));
stores = new DataStores(vaultDir);
activeSession = {
vaultPath: vaultDir,
dataStores: stores,
capabilityLedger: async () => new CapabilityLedger(await stores.open("ledger")),
dashboardStore: async () => makeFakeDashboard(),
};
// A prebuilt "notes" bundle, the shape extraResources ships.
const bundleDir = join(appsDir, "notes");
await mkdir(join(bundleDir, "dist"), { recursive: true });
await writeFile(
join(bundleDir, "manifest.json"),
JSON.stringify({
id: NOTES_ID,
name: "Notes",
version: "1.0.0",
sdk: "1",
entry: "dist/index.html",
capabilities: [],
}),
"utf8",
);
await writeFile(join(bundleDir, "dist", "index.html"), "<!doctype html>", "utf8");
});

afterEach(async () => {
activeSession = null;
stores.close();
await rm(vaultDir, { recursive: true, force: true });
await rm(appsDir, { recursive: true, force: true });
});

it("installs the prebuilt bundle with bootstrap-cache provenance and never spawns a build", async () => {
const result = await reinstallFirstPartyApp(NOTES_ID, appsDir, { prebuiltOnly: true });
expect(result).toEqual({ ok: true });
expect(spawnSpy).not.toHaveBeenCalled();
const repo = new AppsRepository(await stores.open("registry"));
expect(repo.getActive(NOTES_ID)?.origin).toBe(InstallOrigin.BootstrapCache);
});

it("fails with a clear reason (still no spawn) when the prebuilt bundle is missing", async () => {
await rm(join(appsDir, "notes"), { recursive: true, force: true });
const result = await reinstallFirstPartyApp(NOTES_ID, appsDir, { prebuiltOnly: true });
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason.length).toBeGreaterThan(0);
expect(spawnSpy).not.toHaveBeenCalled();
});
});

/**
* F-436 — a failed app build must report something a dev can act on.
*
Expand Down
30 changes: 24 additions & 6 deletions packages/shell/src/main/dev/seed-demo-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ function sharedSourceRoots(appsDir: string): readonly string[] {

export type ReinstallResult = { ok: true } | { ok: false; reason: string };

export type ReinstallOptions = {
/** Packaged-shell mode: install the already-built bundle at
* `<appsDir>/<dir>` as-is (`bootstrap-cache` provenance) and NEVER spawn
* `vite build` — a packaged build has no sources, no bun on PATH, and a
* read-only resources tree, so a spawn there could only fail confusingly. */
prebuiltOnly?: boolean;
};

/**
* Reinstall a single first-party app by manifest id — the marketplace's
* "Install" affordance for a built-in app the user previously uninstalled.
Expand All @@ -261,6 +269,7 @@ export type ReinstallResult = { ok: true } | { ok: false; reason: string };
export async function reinstallFirstPartyApp(
appId: string,
appsDir: string,
options: ReinstallOptions = {},
): Promise<ReinstallResult> {
const app = firstPartyAppById(appId);
if (!app) return { ok: false, reason: `${appId} is not a first-party app` };
Expand All @@ -283,12 +292,21 @@ export async function reinstallFirstPartyApp(
// they previously removed the icon from the dashboard.
dashboard.clearAppIconDismissed(app.expectedAppId);

const outcome = await buildInstallPin(app, appsDir, {
installer,
appsRepo,
dashboard,
provenance: DEV_INSTALL_PROVENANCE,
});
// Prebuilt mode omits `provenance` so the installer stamps its default
// (`bootstrap-cache`) — the same row the packaged bootstrap installer
// writes, so the update engine reconciles it against the catalog.
const outcome = options.prebuiltOnly
? await installPrebuiltBundle(app, join(appsDir, app.dir), {
installer,
appsRepo,
dashboard,
})
: await buildInstallPin(app, appsDir, {
installer,
appsRepo,
dashboard,
provenance: DEV_INSTALL_PROVENANCE,
});
return outcome.ok ? { ok: true } : { ok: false, reason: outcome.reason };
}

Expand Down
4 changes: 4 additions & 0 deletions packages/shell/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ import {
import { registerDashboard } from "./ipc/renderer-identity";
import { registerSearchHandlers } from "./ipc/search-handlers";
import { registerShortcutsHandlers } from "./ipc/shortcuts-handlers";
import { registerSideloadHandlers } from "./ipc/sideload-handlers";
import { registerSpellcheckHandlers } from "./ipc/spellcheck-handlers";
import { registerSyncStatusHandlers } from "./ipc/sync-status-handlers";
import { registerAutoUpdateHandlers, registerUpdateHandlers } from "./ipc/update-handlers";
Expand Down Expand Up @@ -1965,6 +1966,9 @@ void app.whenReady().then(async () => {
const activityStore = new BackgroundActivityStore();
registerActivityHandlers({ getDashboard: () => dashboardWindow, store: activityStore });
registerMarketplaceHandlers({ mainDir: __dirname });
// Local (folder / .brainstorm) app installs — dashboard-only, user-gesture
// gated (AppForge-1).
registerSideloadHandlers({ getDashboard: () => dashboardWindow });
registerIconsHandlers({ getDashboard: () => dashboardWindow });
registerCoversHandlers({ getDashboard: () => dashboardWindow });
registerImportExportHandlers({
Expand Down
Loading
Loading