From 2fb3e624f525ff54067c427205460ac4eda4cd3d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 21 May 2026 23:38:28 +0300 Subject: [PATCH 01/10] feat(manifest): add sandbox and content security policy support for MV2 and MV3 - Add `addSandbox`, `appendSandboxes`, and `setSandboxContentSecurityPolicy` methods - Implement builders for sandbox pages and content security policies in MV2 and MV3 - Update tests for manifest sandbox functionality --- .../builders/manifest/ManifestBase.test.ts | 86 +++++++++++++++++++ src/cli/builders/manifest/ManifestBase.ts | 48 +++++++++++ src/cli/builders/manifest/ManifestV2.ts | 30 +++++++ src/cli/builders/manifest/ManifestV3.ts | 61 +++++++++++++ src/types/manifest.ts | 13 +++ 5 files changed, 238 insertions(+) diff --git a/src/cli/builders/manifest/ManifestBase.test.ts b/src/cli/builders/manifest/ManifestBase.test.ts index 204542c..f607fcb 100644 --- a/src/cli/builders/manifest/ManifestBase.test.ts +++ b/src/cli/builders/manifest/ManifestBase.test.ts @@ -155,6 +155,92 @@ describe("ManifestBase primitive properties", () => { }); }); +describe("ManifestBase sandbox properties", () => { + test("builds MV3 sandbox pages and content security policy", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder + .raw({ + sandbox: {pages: ["sandbox/raw.html"]}, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self';", + sandbox: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .appendSandboxes(["sandbox/parser.html", "sandbox/parser.html"]) + .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); + + const manifest: any = builder.build(); + + expect(manifest.sandbox.pages).toEqual(["sandbox/raw.html", "sandbox/parser.html"]); + expect(manifest.content_security_policy.extension_pages).toBe("script-src 'self'; object-src 'self';"); + expect(manifest.content_security_policy.sandbox).toBe( + "sandbox allow-scripts; script-src 'self' 'unsafe-eval';" + ); + }); + + test("builds MV2 sandbox content security policy inside the sandbox object", () => { + const builder = new ManifestV2(Browser.Chrome); + + builder + .raw({ + sandbox: { + pages: ["sandbox/raw.html"], + content_security_policy: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .addSandbox("sandbox/parser.html") + .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); + + const manifest: any = builder.build(); + + expect(manifest.sandbox.pages).toEqual(["sandbox/raw.html", "sandbox/parser.html"]); + expect(manifest.sandbox.content_security_policy).toBe( + "sandbox allow-scripts; script-src 'self' 'unsafe-eval';" + ); + }); + + test("does not emit sandbox manifest fields for Firefox MV3", () => { + const builder = new ManifestV3(Browser.Firefox); + + builder + .raw({ + sandbox: {pages: ["sandbox/raw.html"]}, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self';", + sandbox: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .appendSandboxes(["sandbox/parser.html"]) + .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); + + const manifest: any = builder.build(); + + expect(manifest.sandbox).toBeUndefined(); + expect(manifest.content_security_policy).toEqual({ + extension_pages: "script-src 'self'; object-src 'self';", + }); + }); + + test("does not emit sandbox manifest fields for Firefox MV2", () => { + const builder = new ManifestV2(Browser.Firefox); + + builder + .raw({ + sandbox: { + pages: ["sandbox/raw.html"], + content_security_policy: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .addSandbox("sandbox/parser.html") + .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); + + const manifest: any = builder.build(); + + expect(manifest.sandbox).toBeUndefined(); + }); +}); + describe("ManifestBase merged properties", () => { it("merging objects and arrays", () => { const builder = new ManifestV3(Browser.Chrome); diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index ed3fabd..d4f2e2c 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -18,6 +18,9 @@ import { ManifestOptionalPermissions, ManifestPermissions, ManifestPopup, + ManifestSandbox, + ManifestSandboxContentSecurityPolicy, + ManifestSandboxes, ManifestSidebar, ManifestVersion, OptionalManifest, @@ -56,6 +59,8 @@ export default abstract class implements ManifestBuilder protected background?: ManifestBackground; protected popup?: ManifestPopup; protected sidebar?: ManifestSidebar; + protected sandboxes: ManifestSandboxes = new Set(); + protected sandboxContentSecurityPolicy?: ManifestSandboxContentSecurityPolicy; protected commands: ManifestCommands = new Set(); protected contentScripts: ManifestContentScripts = new Set(); protected dependencies: ManifestDependencies = new Map(); @@ -82,6 +87,10 @@ export default abstract class implements ManifestBuilder protected abstract buildWebAccessibleResources(): Partial | undefined; + protected abstract buildSandbox(): Partial | undefined; + + protected abstract buildContentSecurityPolicy(): Partial | undefined; + protected get combinedRaws(): OptionalManifest { return (this.mergedRaws ??= Array.from(this.raws).reduce((result, raw) => { return _.mergeWith(result, raw, (objValue, srcValue) => { @@ -252,6 +261,26 @@ export default abstract class implements ManifestBuilder return this; } + public addSandbox(sandbox: ManifestSandbox): this { + this.sandboxes.add(sandbox); + + return this; + } + + public appendSandboxes(sandboxes: Iterable): this { + for (const sandbox of sandboxes) { + this.addSandbox(sandbox); + } + + return this; + } + + public setSandboxContentSecurityPolicy(policy?: ManifestSandboxContentSecurityPolicy): this { + this.sandboxContentSecurityPolicy = policy; + + return this; + } + public setDependencies(dependencies: ManifestDependencies): this { this.dependencies = dependencies; @@ -387,6 +416,8 @@ export default abstract class implements ManifestBuilder this.buildHostPermissions(), this.buildOptionalHostPermissions(), this.buildWebAccessibleResources(), + this.buildSandbox(), + this.buildContentSecurityPolicy(), this.buildBrowserSpecificSettings(), this.buildRaw() ) as T; @@ -680,6 +711,8 @@ export default abstract class implements ManifestBuilder host_permissions, optional_host_permissions, web_accessible_resources, + sandbox, + content_security_policy, browser_specific_settings, ...other } = this.combinedRaws; @@ -687,6 +720,21 @@ export default abstract class implements ManifestBuilder return other; } + protected getSandboxes(): string[] { + const sandboxes = new Set(); + const rawSandbox = this.combinedRaws.sandbox as {pages?: string[]} | undefined; + + for (const sandbox of rawSandbox?.pages || []) { + sandboxes.add(sandbox); + } + + for (const sandbox of this.sandboxes) { + sandboxes.add(sandbox); + } + + return Array.from(sandboxes); + } + protected hasExecuteActionCommand(): boolean { const optionalCommands = this.combinedRaws.commands; diff --git a/src/cli/builders/manifest/ManifestV2.ts b/src/cli/builders/manifest/ManifestV2.ts index 8ca5f81..7eb422e 100644 --- a/src/cli/builders/manifest/ManifestV2.ts +++ b/src/cli/builders/manifest/ManifestV2.ts @@ -115,4 +115,34 @@ export default class extends ManifestBase { return {web_accessible_resources: Array.from(new Set(resources))}; } } + + protected buildSandbox(): Partial | undefined { + if (this.browser === Browser.Firefox) { + return; + } + + const rawSandbox = (this.combinedRaws.sandbox || {}) as Record; + const sandboxes = this.getSandboxes(); + const contentSecurityPolicy = this.sandboxContentSecurityPolicy || rawSandbox.content_security_policy; + + if (sandboxes.length === 0 && !contentSecurityPolicy && Object.keys(rawSandbox).length === 0) { + return; + } + + return { + sandbox: { + ...rawSandbox, + pages: sandboxes, + ...(contentSecurityPolicy ? {content_security_policy: contentSecurityPolicy} : {}), + }, + } as Partial; + } + + protected buildContentSecurityPolicy(): Partial | undefined { + const contentSecurityPolicy = this.combinedRaws.content_security_policy; + + return contentSecurityPolicy + ? ({content_security_policy: contentSecurityPolicy} as Partial) + : undefined; + } } diff --git a/src/cli/builders/manifest/ManifestV3.ts b/src/cli/builders/manifest/ManifestV3.ts index c8bdccb..d2a6498 100644 --- a/src/cli/builders/manifest/ManifestV3.ts +++ b/src/cli/builders/manifest/ManifestV3.ts @@ -120,4 +120,65 @@ export default class extends ManifestBase { return {web_accessible_resources: transformedResources}; } } + + protected buildSandbox(): Partial | undefined { + if (this.browser === Browser.Firefox) { + return; + } + + const rawSandbox = (this.combinedRaws.sandbox || {}) as Record; + const sandboxes = this.getSandboxes(); + + if (sandboxes.length === 0 && Object.keys(rawSandbox).length === 0) { + return; + } + + return { + sandbox: { + ...rawSandbox, + pages: sandboxes, + }, + } as Partial; + } + + protected buildContentSecurityPolicy(): Partial | undefined { + const rawContentSecurityPolicy = this.combinedRaws.content_security_policy; + + if (this.browser === Browser.Firefox) { + if (!rawContentSecurityPolicy) { + return; + } + + if (typeof rawContentSecurityPolicy === "string") { + return {content_security_policy: rawContentSecurityPolicy} as Partial; + } + + const {sandbox, ...contentSecurityPolicy} = rawContentSecurityPolicy; + + return Object.keys(contentSecurityPolicy).length > 0 + ? ({content_security_policy: contentSecurityPolicy} as Partial) + : undefined; + } + + if (!rawContentSecurityPolicy && !this.sandboxContentSecurityPolicy) { + return; + } + + if (typeof rawContentSecurityPolicy === "string") { + if (this.sandboxContentSecurityPolicy) { + throw new ManifestError( + "Cannot merge sandbox content security policy with a string content_security_policy." + ); + } + + return {content_security_policy: rawContentSecurityPolicy} as Partial; + } + + return { + content_security_policy: { + ...rawContentSecurityPolicy, + sandbox: this.sandboxContentSecurityPolicy || rawContentSecurityPolicy?.sandbox, + }, + } as Partial; + } } diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 70fb335..2ab57d5 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -126,6 +126,13 @@ export interface ManifestBuilder { setSidebar(sidebar?: ManifestSidebar): this; + // Sandbox + addSandbox(sandbox: ManifestSandbox): this; + + appendSandboxes(sandboxes: Iterable): this; + + setSandboxContentSecurityPolicy(policy?: ManifestSandboxContentSecurityPolicy): this; + // System setDependencies(dependencies: ManifestDependencies): this; @@ -229,6 +236,12 @@ export interface ManifestAccessibleResource { export type ManifestAccessibleResources = Set; +export type ManifestSandbox = string; + +export type ManifestSandboxes = Set; + +export type ManifestSandboxContentSecurityPolicy = string; + export interface ManifestDependency { js: Set; css: Set; From 26f80999f185dee7e6394678a4cc3f9bde2e6091 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 21 May 2026 23:40:59 +0300 Subject: [PATCH 02/10] refactor(transport): refactor transport interfaces and add sandbox registry support - Split `TransportMessage` into `TransportSender` and `TransportReceiver` - Add `TransportMessage` implementation combining sender and receiver - Introduce `TransportDeclarationLayer.Sandbox` and `sandbox.d.ts` handling - Implement `TransportBuilder` for sandbox transport initialization - Add `destroy` method and cleanup mechanism to `RegisterTransport` --- .../transport/TransportDeclaration.test.ts | 9 +++++++++ src/entry/sandbox/TransportBuilder.ts | 18 ++++++++++++++++++ src/transport/RegisterTransport.ts | 15 ++++++++++++--- src/transport/TransportMessage.ts | 4 ++-- src/types/transport.ts | 10 +++++++--- 5 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 src/entry/sandbox/TransportBuilder.ts diff --git a/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.test.ts b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.test.ts index addbc4a..ff8cb76 100644 --- a/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.test.ts +++ b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.test.ts @@ -51,6 +51,15 @@ describe("TransportDeclaration", () => { layer: TransportDeclarationLayer.Relay, registry: "RelayRegistry", }, + { + dictionary: { + alpha: "{ call(value: string): Promise; }", + beta: "{ nested: { ping(): boolean; }; }", + }, + filename: "sandbox.d.ts", + layer: TransportDeclarationLayer.Sandbox, + registry: "SandboxRegistry", + }, ]; test.each(cases)("writes a strict $layer registry", ({dictionary, filename, layer, registry}) => { diff --git a/src/entry/sandbox/TransportBuilder.ts b/src/entry/sandbox/TransportBuilder.ts new file mode 100644 index 0000000..36fd10a --- /dev/null +++ b/src/entry/sandbox/TransportBuilder.ts @@ -0,0 +1,18 @@ +import AbstractBuilder from "@entry/transport/AbstractBuilder"; + +import {RegisterSandbox} from "@sandbox/providers"; + +import type {SandboxOptions, SandboxUnresolvedDefinition} from "@typing/sandbox"; +import type {TransportName, TransportType} from "@typing/transport"; + +export default class extends AbstractBuilder { + constructor(definition: SandboxUnresolvedDefinition) { + super(definition); + } + + protected transport(): RegisterSandbox { + const {name, init} = this.definition; + + return new RegisterSandbox(name, init); + } +} diff --git a/src/transport/RegisterTransport.ts b/src/transport/RegisterTransport.ts index 1de665b..05153a7 100644 --- a/src/transport/RegisterTransport.ts +++ b/src/transport/RegisterTransport.ts @@ -2,7 +2,7 @@ import get from "get-value"; import BaseTransport from "./BaseTransport"; -import {TransportDictionary, TransportMessage, TransportName, TransportRegister} from "@typing/transport"; +import {TransportDictionary, TransportName, TransportReceiver, TransportRegister} from "@typing/transport"; import {MessageSender, MessageSenderProperty} from "@typing/message"; // prettier-ignore @@ -11,6 +11,8 @@ export default abstract class< T extends object = TransportDictionary[N], A extends any[] = [] > extends BaseTransport implements TransportRegister { + private unwatch?: () => void; + protected constructor( name: N, protected readonly init: (...args: A) => T @@ -18,7 +20,7 @@ export default abstract class< super(name); } - protected abstract message(): TransportMessage; + protected abstract message(): TransportReceiver; public register(...args: A): T { if (this.manager().has(this.name)) { @@ -29,7 +31,7 @@ export default abstract class< this.manager().add(this.name, instance); - this.message().watch(async ({path, args}, sender) => { + this.unwatch = this.message().watch(async ({path, args}, sender) => { try { const context = this.withSender(instance, sender); @@ -58,6 +60,13 @@ export default abstract class< return instance; } + public destroy(): void { + this.unwatch?.(); + this.unwatch = undefined; + + super.destroy(); + } + private withSender(instance: T, sender: MessageSender): T { return new Proxy(instance, { get(target, property, receiver) { diff --git a/src/transport/TransportMessage.ts b/src/transport/TransportMessage.ts index 3e398d1..828adca 100644 --- a/src/transport/TransportMessage.ts +++ b/src/transport/TransportMessage.ts @@ -12,7 +12,7 @@ export default abstract class TransportMessage implements TransportMessageContra return this.message.send(this.key, data, options); } - public watch(handler: (data: TransportMessageData, sender: MessageSender) => any): void { - this.message.watch(this.key, handler); + public watch(handler: (data: TransportMessageData, sender: MessageSender) => any): () => void { + return this.message.watch(this.key, handler); } } diff --git a/src/types/transport.ts b/src/types/transport.ts index aa8daff..b4bffad 100644 --- a/src/types/transport.ts +++ b/src/types/transport.ts @@ -33,12 +33,16 @@ export interface TransportMessageData { args: any[]; } -export interface TransportMessage { - send(data: TransportMessageData, options?: MessageSendOptions): any; +export interface TransportReceiver { + watch(handler: (data: TransportMessageData, sender: MessageSender) => any): () => void; +} - watch(handler: (data: TransportMessageData, sender: MessageSender) => any): void; +export interface TransportSender { + send(data: TransportMessageData, options?: MessageSendOptions): any; } +export interface TransportMessage extends TransportSender, TransportReceiver {} + export interface TransportProvider { get(): T; From 2ea2fb05677e770dfaadecf4aaf5b9561d6fed37 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 21 May 2026 23:42:17 +0300 Subject: [PATCH 03/10] refactor(message): extract error handling into dedicated utility module - Move `serializeError` and `restoreError` from `MessageManager` to `error.ts` - Replace inline error handling with shared utility functions across the message layer - Add comprehensive tests for error serialization and restoration logic --- src/message/MessageManager.ts | 34 ++------------- src/message/error.test.ts | 39 ++++++++++++++++++ src/message/error.ts | 71 ++++++++++++++++++++++++++++++++ src/message/providers/Message.ts | 36 ++-------------- 4 files changed, 116 insertions(+), 64 deletions(-) create mode 100644 src/message/error.test.ts create mode 100644 src/message/error.ts diff --git a/src/message/MessageManager.ts b/src/message/MessageManager.ts index 0c4f775..bbbbfd0 100644 --- a/src/message/MessageManager.ts +++ b/src/message/MessageManager.ts @@ -1,9 +1,10 @@ import {onMessage} from "@addon-core/browser"; +import {serializeError} from "./error"; + import { MessageBody, MessageDictionary, - MessageError, MessageGlobalKey, MessageHandler, MessageResult, @@ -98,35 +99,6 @@ export default class MessageManager { } private failure(error: unknown): MessageResult { - return {[MessageResultEnvelopeProperty]: true, ok: false, error: this.serializeError(error)}; - } - - private serializeError(error: unknown): MessageError { - if (error instanceof Error) { - return this.error(error.name, error.message, error.stack); - } - - if (typeof error === "object" && error !== null) { - const record = error as Record; - const name = typeof record.name === "string" ? record.name : "Error"; - const message = typeof record.message === "string" ? record.message : this.stringifyError(error); - const stack = typeof record.stack === "string" ? record.stack : undefined; - - return this.error(name, message, stack); - } - - return this.error("Error", String(error)); - } - - private stringifyError(error: object): string { - try { - return JSON.stringify(error); - } catch { - return String(error); - } - } - - private error(name: string, message: string, stack?: string): MessageError { - return stack ? {name, message, stack} : {name, message}; + return {[MessageResultEnvelopeProperty]: true, ok: false, error: serializeError(error)}; } } diff --git a/src/message/error.test.ts b/src/message/error.test.ts new file mode 100644 index 0000000..bb15113 --- /dev/null +++ b/src/message/error.test.ts @@ -0,0 +1,39 @@ +import {restoreError, serializeError} from "./error"; + +describe("message error", () => { + test("serializes a real Error with name, message and stack", () => { + const serialized = serializeError(new TypeError("boom")); + + expect(serialized.name).toBe("TypeError"); + expect(serialized.message).toBe("boom"); + expect(typeof serialized.stack).toBe("string"); + }); + + test("serializes error-like objects and primitives", () => { + expect(serializeError({name: "Custom", message: "x"})).toEqual({name: "Custom", message: "x"}); + expect(serializeError({code: 1})).toEqual({name: "Error", message: JSON.stringify({code: 1})}); + expect(serializeError("oops")).toEqual({name: "Error", message: "oops"}); + }); + + test("restores the native constructor from the serialized name", () => { + const restored = restoreError({name: "TypeError", message: "boom"}); + + expect(restored).toBeInstanceOf(TypeError); + expect(restored.name).toBe("TypeError"); + expect(restored.message).toBe("boom"); + }); + + test("round-trips an error through serialize and restore", () => { + const restored = restoreError(serializeError(new RangeError("nope"))); + + expect(restored).toBeInstanceOf(RangeError); + expect(restored.message).toBe("nope"); + }); + + test("falls back when the envelope is missing", () => { + const restored = restoreError(undefined); + + expect(restored).toBeInstanceOf(Error); + expect(restored.message).toBe("Request failed."); + }); +}); diff --git a/src/message/error.ts b/src/message/error.ts new file mode 100644 index 0000000..8b6e566 --- /dev/null +++ b/src/message/error.ts @@ -0,0 +1,71 @@ +import {MessageError} from "@typing/message"; + +const build = (name: string, message: string, stack?: string): MessageError => { + return stack ? {name, message, stack} : {name, message}; +}; + +const stringify = (error: object): string => { + try { + return JSON.stringify(error); + } catch { + return String(error); + } +}; + +const constructorFor = (name: string): new (message?: string) => Error => { + switch (name) { + case "EvalError": + return EvalError; + case "RangeError": + return RangeError; + case "ReferenceError": + return ReferenceError; + case "SyntaxError": + return SyntaxError; + case "TypeError": + return TypeError; + case "URIError": + return URIError; + default: + return Error; + } +}; + +/** + * Convert any thrown value into a transferable {@link MessageError} envelope. Handles real + * `Error` instances, error-like objects, and primitives (JSON-stringifying objects when no + * `message` is present). The standard serialize step for every cross-context transport. + */ +export const serializeError = (error: unknown): MessageError => { + if (error instanceof Error) { + return build(error.name, error.message, error.stack); + } + + if (typeof error === "object" && error !== null) { + const record = error as Record; + const name = typeof record.name === "string" ? record.name : "Error"; + const message = typeof record.message === "string" ? record.message : stringify(error); + const stack = typeof record.stack === "string" ? record.stack : undefined; + + return build(name, message, stack); + } + + return build("Error", String(error)); +}; + +/** + * Reconstruct an `Error` from a {@link MessageError} envelope, mapping the serialized `name` + * back to its native constructor (so `instanceof TypeError` survives the boundary). + */ +export const restoreError = (error?: MessageError): Error => { + const ErrorConstructor = constructorFor(error?.name ?? "Error"); + const restored = new ErrorConstructor(error?.message ?? "Request failed."); + + restored.name = error?.name || "Error"; + + if (error?.stack) { + restored.stack = error.stack; + } + + return restored; +}; diff --git a/src/message/providers/Message.ts b/src/message/providers/Message.ts index 347cb9c..88bb0fc 100644 --- a/src/message/providers/Message.ts +++ b/src/message/providers/Message.ts @@ -1,5 +1,7 @@ import {sendMessage, sendTabMessage} from "@addon-core/browser"; +import {restoreError} from "../error"; + import {isBrowser} from "@main/env"; import { @@ -78,7 +80,7 @@ export default class Message extends AbstractMessag return response.payload; } - throw this.restoreError(response.error); + throw restoreError(response.error); } private isMessageResult(response: unknown): response is MessageResult { @@ -110,38 +112,6 @@ export default class Message extends AbstractMessag return typeof value === "object" && value !== null; } - private restoreError(error: MessageError): Error { - const ErrorConstructor = this.getErrorConstructor(error.name); - const restored = new ErrorConstructor(error.message); - - restored.name = error.name || "Error"; - - if (error.stack) { - restored.stack = error.stack; - } - - return restored; - } - - private getErrorConstructor(name: string): new (message?: string) => Error { - switch (name) { - case "EvalError": - return EvalError; - case "RangeError": - return RangeError; - case "ReferenceError": - return ReferenceError; - case "SyntaxError": - return SyntaxError; - case "TypeError": - return TypeError; - case "URIError": - return URIError; - default: - return Error; - } - } - public watch>( arg1: K | MessageMapHandler | MessageGeneralHandler, arg2?: MessageTargetHandler From 2e385a1eaa6a94206656bf4e928e11af03473d6d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 21 May 2026 23:44:18 +0300 Subject: [PATCH 04/10] chore: enhance type handling and add multiline union alias support in tests --- src/cli/entrypoint/file/ExpressionFile.test.ts | 15 +++++++++++++++ src/cli/entrypoint/file/parsers/TypeResolver.ts | 9 ++++++++- .../entrypoint/file/resolvers/ImportResolver.ts | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/cli/entrypoint/file/ExpressionFile.test.ts b/src/cli/entrypoint/file/ExpressionFile.test.ts index 3d2e9c2..ad4b0c3 100644 --- a/src/cli/entrypoint/file/ExpressionFile.test.ts +++ b/src/cli/entrypoint/file/ExpressionFile.test.ts @@ -127,6 +127,21 @@ describe("ExpressionFile", () => { "{ getUserInfo(): {id: number; name: string;}; getUserDetails(): {id: number; name: string; address?: string; age?: number; data?: {reg: number; log: number;};}; getUserAndDetails(): {id: number; name: string; address?: string; age?: number; data?: {reg: number; log: number;};}; }" ); }); + + test("class with multiline union alias keeps generated type inline", () => { + const filename = path.join( + fixtures, + "type-patterns", + "complex-types", + "class-with-multiline-union-alias.ts" + ); + + const type = ExpressionFile.make(filename).getType(); + + expect(type).toBe( + "{ evaluate(code: string): Promise<{ok: true; type: string; value: string;} | {ok: false; name: string; message: string; stack?: string;}>; }" + ); + }); }); describe("External Library Types", () => { diff --git a/src/cli/entrypoint/file/parsers/TypeResolver.ts b/src/cli/entrypoint/file/parsers/TypeResolver.ts index 2b3bc79..df47135 100644 --- a/src/cli/entrypoint/file/parsers/TypeResolver.ts +++ b/src/cli/entrypoint/file/parsers/TypeResolver.ts @@ -41,7 +41,7 @@ export default class TypeResolver { } // fallback: use textual representation - return typeNode.getText(); + return this.normalizeSourceTypeText(typeNode.getText()); } /** @@ -349,6 +349,13 @@ export default class TypeResolver { return name.getText(); } + /** + * Normalizes raw TypeScript source text before it enters generated declarations. + */ + private normalizeSourceTypeText(text: string): string { + return text.replace(/\s+/g, " ").trim(); + } + /** * Extracts properties from an interface declaration */ diff --git a/src/cli/entrypoint/file/resolvers/ImportResolver.ts b/src/cli/entrypoint/file/resolvers/ImportResolver.ts index 61499db..6d33fd2 100644 --- a/src/cli/entrypoint/file/resolvers/ImportResolver.ts +++ b/src/cli/entrypoint/file/resolvers/ImportResolver.ts @@ -3,7 +3,7 @@ import {createRequire} from "module"; import TsResolver from "./TsResolver"; -import {hasEntrypointPath, resolveEntrypointPath} from "@cli/entrypoint"; +import {hasEntrypointPath, resolveEntrypointPath} from "@cli/entrypoint/utils"; import {PackageName} from "@typing/app"; From 73508888a817844bb724877347d2ed501d9d4353 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 21 May 2026 23:45:01 +0300 Subject: [PATCH 05/10] feat(sandbox): introduce sandbox message system and host/iframe communication support - Add `SandboxMessage`, `SandboxHost`, `SandboxInner`, and `SandboxMemory` classes. - Implement in-memory and iframe-based sandbox communication. - Add `ReadyFrame` utility for iframe readiness handling. - Extend tests to cover sandbox message system, frame initialization, and transport. --- jest.config.ts | 2 + package.json | 4 + src/cli/entrypoint/file/injectors/core.ts | 19 ++ .../class-with-multiline-union-alias.ts | 24 +++ src/cli/entrypoint/finder/SandboxFinder.ts | 30 +++ .../entrypoint/finder/SandboxViewFinder.ts | 43 +++++ src/cli/entrypoint/finder/index.ts | 2 + .../entrypoint/parser/SandboxParser.test.ts | 118 ++++++++++++ src/cli/entrypoint/parser/SandboxParser.ts | 56 ++++++ src/cli/entrypoint/parser/index.ts | 1 + .../sandbox/contracts/class/init-class.ts | 24 +++ .../sandbox/contracts/imported/api.ts | 9 + .../contracts/imported/init-imported-class.ts | 10 + .../sandbox/contracts/object/init-object.ts | 16 ++ .../sandbox/invalid/allow-same-origin.ts | 11 ++ .../fixtures/sandbox/invalid/invalid-name.ts | 8 + .../sandbox/invalid/unknown-source.ts | 13 ++ .../fixtures/sandbox/options/full/sandbox.ts | 43 +++++ .../options/imported-values/options.ts | 18 ++ .../options/imported-values/sandbox.ts | 13 ++ src/cli/plugins/index.ts | 1 + src/cli/plugins/sandbox/Sandbox.ts | 80 ++++++++ src/cli/plugins/sandbox/SandboxCsp.test.ts | 37 ++++ src/cli/plugins/sandbox/SandboxCsp.ts | 81 ++++++++ src/cli/plugins/sandbox/SandboxDeclaration.ts | 9 + src/cli/plugins/sandbox/index.ts | 77 ++++++++ .../transport/TransportDeclaration.ts | 1 + src/cli/resolvers/config.test.ts | 1 + src/cli/resolvers/config.ts | 4 + src/cli/virtual/index.ts | 9 +- src/cli/virtual/sandbox.ts | 38 ++++ src/cli/virtual/virtual.d.ts | 27 +++ src/entry/sandbox/Builder.ts | 66 +++++++ src/entry/sandbox/index.ts | 2 + src/frame/ReadyFrame.test.ts | 144 ++++++++++++++ src/frame/ReadyFrame.ts | 124 ++++++++++++ src/frame/index.ts | 2 + src/main/index.ts | 1 + src/main/sandbox.ts | 40 ++++ src/offscreen/OffscreenBridge.ts | 88 ++------- src/sandbox/SandboxFrame.ts | 38 ++++ src/sandbox/SandboxManager.ts | 11 ++ src/sandbox/SandboxMessage.test.ts | 81 ++++++++ src/sandbox/SandboxMessage.ts | 179 ++++++++++++++++++ src/sandbox/index.ts | 11 ++ src/sandbox/ports/SandboxHost.ts | 79 ++++++++ src/sandbox/ports/SandboxInner.ts | 62 ++++++ src/sandbox/ports/SandboxMemory.ts | 59 ++++++ src/sandbox/ports/index.ts | 3 + src/sandbox/providers/ProxySandbox.ts | 25 +++ src/sandbox/providers/RegisterSandbox.ts | 37 ++++ src/sandbox/providers/Sandbox.test.ts | 132 +++++++++++++ src/sandbox/providers/index.ts | 2 + src/sandbox/utils.ts | 9 + src/types/config.ts | 8 + src/types/entrypoint.ts | 1 + src/types/plugin.ts | 3 +- src/types/sandbox.ts | 120 ++++++++++++ tsconfig.json | 2 + 59 files changed, 2081 insertions(+), 77 deletions(-) create mode 100644 src/cli/entrypoint/file/tests/fixtures/expression/type-patterns/complex-types/class-with-multiline-union-alias.ts create mode 100644 src/cli/entrypoint/finder/SandboxFinder.ts create mode 100644 src/cli/entrypoint/finder/SandboxViewFinder.ts create mode 100644 src/cli/entrypoint/parser/SandboxParser.test.ts create mode 100644 src/cli/entrypoint/parser/SandboxParser.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/class/init-class.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/api.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/init-imported-class.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/object/init-object.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/allow-same-origin.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/invalid-name.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/unknown-source.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/options.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/sandbox.ts create mode 100644 src/cli/plugins/sandbox/Sandbox.ts create mode 100644 src/cli/plugins/sandbox/SandboxCsp.test.ts create mode 100644 src/cli/plugins/sandbox/SandboxCsp.ts create mode 100644 src/cli/plugins/sandbox/SandboxDeclaration.ts create mode 100644 src/cli/plugins/sandbox/index.ts create mode 100644 src/cli/virtual/sandbox.ts create mode 100644 src/entry/sandbox/Builder.ts create mode 100644 src/entry/sandbox/index.ts create mode 100644 src/frame/ReadyFrame.test.ts create mode 100644 src/frame/ReadyFrame.ts create mode 100644 src/frame/index.ts create mode 100644 src/main/sandbox.ts create mode 100644 src/sandbox/SandboxFrame.ts create mode 100644 src/sandbox/SandboxManager.ts create mode 100644 src/sandbox/SandboxMessage.test.ts create mode 100644 src/sandbox/SandboxMessage.ts create mode 100644 src/sandbox/index.ts create mode 100644 src/sandbox/ports/SandboxHost.ts create mode 100644 src/sandbox/ports/SandboxInner.ts create mode 100644 src/sandbox/ports/SandboxMemory.ts create mode 100644 src/sandbox/ports/index.ts create mode 100644 src/sandbox/providers/ProxySandbox.ts create mode 100644 src/sandbox/providers/RegisterSandbox.ts create mode 100644 src/sandbox/providers/Sandbox.test.ts create mode 100644 src/sandbox/providers/index.ts create mode 100644 src/sandbox/utils.ts create mode 100644 src/types/sandbox.ts diff --git a/jest.config.ts b/jest.config.ts index 0b70d30..1c895ef 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -7,9 +7,11 @@ const config: Config = { moduleNameMapper: { "^@cli/(.*)$": "/src/cli/$1", "^@entry/(.*)$": "/src/entry/$1", + "^@frame/(.*)$": "/src/frame/$1", "^@locale/(.*)$": "/src/locale/$1", "^@offscreen/(.*)$": "/src/offscreen/$1", "^@message/(.*)$": "/src/message/$1", + "^@sandbox/(.*)$": "/src/sandbox/$1", "^@service/(.*)$": "/src/service/$1", "^@storage/(.*)$": "/src/storage/$1", "^@transport/(.*)$": "/src/transport/$1", diff --git a/package.json b/package.json index 01237bd..6b4d294 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,10 @@ "types": "./dist/relay/index.d.ts", "default": "./dist/relay/index.js" }, + "./sandbox": { + "types": "./dist/sandbox/index.d.ts", + "default": "./dist/sandbox/index.js" + }, "./service": { "types": "./dist/service/index.d.ts", "default": "./dist/service/index.js" diff --git a/src/cli/entrypoint/file/injectors/core.ts b/src/cli/entrypoint/file/injectors/core.ts index 841ed41..12c30b8 100644 --- a/src/cli/entrypoint/file/injectors/core.ts +++ b/src/cli/entrypoint/file/injectors/core.ts @@ -3,6 +3,7 @@ import {Browser} from "@typing/browser"; import {RelayMethod} from "@typing/relay"; import {ContentScriptAppend, ContentScriptDeclarative, ContentScriptMarker} from "@typing/content"; import {OffscreenReason} from "@typing/offscreen"; +import {SandboxAllow, SandboxSource} from "@typing/sandbox"; import {Injector} from "../types"; @@ -81,5 +82,23 @@ export default (): Injector[] => { }); }); + Object.entries(SandboxAllow).forEach(([key, value]) => { + resolvers.push({ + from: PackageName, + target: "SandboxAllow", + name: key, + value, + }); + }); + + Object.entries(SandboxSource).forEach(([key, value]) => { + resolvers.push({ + from: PackageName, + target: "SandboxSource", + name: key, + value, + }); + }); + return resolvers; }; diff --git a/src/cli/entrypoint/file/tests/fixtures/expression/type-patterns/complex-types/class-with-multiline-union-alias.ts b/src/cli/entrypoint/file/tests/fixtures/expression/type-patterns/complex-types/class-with-multiline-union-alias.ts new file mode 100644 index 0000000..d444842 --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/expression/type-patterns/complex-types/class-with-multiline-union-alias.ts @@ -0,0 +1,24 @@ +type EvalResult = + | { + ok: true; + type: string; + value: string; + } + | { + ok: false; + name: string; + message: string; + stack?: string; + }; + +class MultilineUnionAlias { + public evaluate(code: string): Promise { + return Promise.resolve({ + ok: true, + type: "string", + value: code, + }); + } +} + +export default () => new MultilineUnionAlias(); diff --git a/src/cli/entrypoint/finder/SandboxFinder.ts b/src/cli/entrypoint/finder/SandboxFinder.ts new file mode 100644 index 0000000..95cfd5e --- /dev/null +++ b/src/cli/entrypoint/finder/SandboxFinder.ts @@ -0,0 +1,30 @@ +import AbstractTransportFinder from "./AbstractTransportFinder"; +import PluginFinder from "./PluginFinder"; + +import {SandboxParser} from "../parser"; + +import {ReadonlyConfig} from "@typing/config"; +import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; +import {SandboxEntrypointOptions, SandboxOptions} from "@typing/sandbox"; + +export default class extends AbstractTransportFinder { + constructor(config: ReadonlyConfig) { + super(config); + } + + public type(): EntrypointType { + return EntrypointType.Sandbox; + } + + protected getParser(): EntrypointParser { + return new SandboxParser(this.config); + } + + protected getPlugin(): EntrypointOptionsFinder { + return new PluginFinder(this.config, "sandbox", this); + } + + public canMerge(): boolean { + return this.config.mergeSandbox; + } +} diff --git a/src/cli/entrypoint/finder/SandboxViewFinder.ts b/src/cli/entrypoint/finder/SandboxViewFinder.ts new file mode 100644 index 0000000..4090e10 --- /dev/null +++ b/src/cli/entrypoint/finder/SandboxViewFinder.ts @@ -0,0 +1,43 @@ +import AbstractViewFinder, {ViewItems} from "./AbstractViewFinder"; +import AbstractTransportFinder from "./AbstractTransportFinder"; + +import {ReadonlyConfig} from "@typing/config"; +import {SandboxEntrypointOptions} from "@typing/sandbox"; +import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; + +export default class extends AbstractViewFinder { + constructor( + config: ReadonlyConfig, + protected readonly finder: AbstractTransportFinder + ) { + super(config); + } + + public type(): EntrypointType { + return EntrypointType.Sandbox; + } + + protected getParser(): EntrypointParser { + return this.finder.parser(); + } + + protected getPlugin(): EntrypointOptionsFinder { + return this.finder.plugin(); + } + + protected async getViews(): Promise> { + const views = await super.getViews(); + + for (const view of views.values()) { + const {name, csp, readyTimeout, requestTimeout, removeOnRequestTimeout, ...options} = view.options; + + view.options = options; + } + + return views; + } + + public canMerge(): boolean { + return this.config.mergeSandbox; + } +} diff --git a/src/cli/entrypoint/finder/index.ts b/src/cli/entrypoint/finder/index.ts index 7eaba1f..0b0ea96 100644 --- a/src/cli/entrypoint/finder/index.ts +++ b/src/cli/entrypoint/finder/index.ts @@ -19,6 +19,8 @@ export {default as OffscreenViewFinder} from "./OffscreenViewFinder"; export {default as PageFinder} from "./PageFinder"; export {default as PopupFinder} from "./PopupFinder"; export {default as RelayFinder} from "./RelayFinder"; +export {default as SandboxFinder} from "./SandboxFinder"; +export {default as SandboxViewFinder} from "./SandboxViewFinder"; export {default as ServiceFinder} from "./ServiceFinder"; export {default as PluginFinder} from "./PluginFinder"; export {default as SidebarFinder} from "./SidebarFinder"; diff --git a/src/cli/entrypoint/parser/SandboxParser.test.ts b/src/cli/entrypoint/parser/SandboxParser.test.ts new file mode 100644 index 0000000..9f6c70d --- /dev/null +++ b/src/cli/entrypoint/parser/SandboxParser.test.ts @@ -0,0 +1,118 @@ +import path from "path"; + +import SandboxParser from "./SandboxParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "sandbox"); + +const parser = new SandboxParser({rootDir} as ReadonlyConfig); + +const file = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return { + file: filename, + import: filename, + }; +}; + +const parseOptions = (...parts: string[]) => parser.options(file(...parts)); +const parseContract = (...parts: string[]) => parser.contract(file(...parts)); + +describe("SandboxParser", () => { + describe("options", () => { + test("parses full sandbox options from a real entrypoint file", () => { + expect(parseOptions("options", "full", "sandbox.ts")).toEqual({ + as: "unsafe-parser-frame", + title: "Unsafe parser", + template: "./template.html", + includeApp: ["admin"], + excludeApp: ["legacy"], + includeBrowser: ["chrome"], + excludeBrowser: ["firefox"], + mode: "production", + debug: true, + manifestVersion: 3, + name: "parser", + readyTimeout: 1000, + requestTimeout: 2000, + removeOnRequestTimeout: true, + csp: { + eval: true, + inline: false, + allow: ["forms", "modals"], + sources: { + connect: ["'self'"], + image: ["'self'", "data:", "blob:"], + style: ["'self'", "'unsafe-inline'"], + font: ["'self'"], + media: ["blob:"], + worker: ["blob:"], + child: ["'self'"], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: "sandbox-test", + content: "enabled", + }, + }, + }); + }); + + test("resolves imported constants and local enum values", () => { + expect(parseOptions("options", "imported-values", "sandbox.ts")).toEqual({ + name: "importedOptions", + readyTimeout: 500, + requestTimeout: 1500, + csp: { + eval: true, + inline: true, + allow: ["popups", "downloads"], + sources: { + image: ["data:", "blob:"], + connect: ["'self'"], + }, + }, + }); + }); + + test("rejects allow-same-origin sandbox token", () => { + expect(() => parseOptions("invalid", "allow-same-origin.ts")).toThrow("Invalid options csp, allow, 0"); + }); + + test("rejects unknown sandbox source values", () => { + expect(() => parseOptions("invalid", "unknown-source.ts")).toThrow( + "Invalid options csp, sources, image, 0" + ); + }); + + test("rejects invalid sandbox names", () => { + expect(() => parseOptions("invalid", "invalid-name.ts")).toThrow("Invalid options name"); + }); + }); + + describe("contract", () => { + test("extracts object literal API returned from init", () => { + expect(parseContract("contracts", "object", "init-object.ts")).toBe( + "{ parse(html: string): number; version: string; }" + ); + }); + + test("extracts class instance API returned from init", () => { + expect(parseContract("contracts", "class", "init-class.ts")).toBe( + "{ prefix: string; parse(html: string): number; normalize(html: string): string; }" + ); + }); + + test("extracts imported class instance API returned from init", () => { + expect(parseContract("contracts", "imported", "init-imported-class.ts")).toBe( + "{ render(template: string, values: Record): string; count(html: string): number; }" + ); + }); + }); +}); diff --git a/src/cli/entrypoint/parser/SandboxParser.ts b/src/cli/entrypoint/parser/SandboxParser.ts new file mode 100644 index 0000000..e2b72db --- /dev/null +++ b/src/cli/entrypoint/parser/SandboxParser.ts @@ -0,0 +1,56 @@ +import z from "zod"; + +import ViewParser from "./ViewParser"; + +import {SandboxAllow, SandboxEntrypointOptions, SandboxSource} from "@typing/sandbox"; + +const sandboxAllowValues = Object.values(SandboxAllow) as [string, ...string[]]; +const sandboxSourceValues = Object.values(SandboxSource) as [string, ...string[]]; + +export default class extends ViewParser { + protected definition(): string { + return "defineSandbox"; + } + + protected agreement(): string { + return "init"; + } + + protected schema(): typeof this.CommonPropertiesSchema { + const sources = z.array(z.enum(sandboxSourceValues)).optional(); + + return super.schema().extend({ + name: z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[\p{L}_$][\p{L}\p{N}_$]*$/u, { + message: + "Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`", + }) + .optional(), + readyTimeout: z.number().positive().optional(), + requestTimeout: z.number().positive().optional(), + removeOnRequestTimeout: z.boolean().optional(), + csp: z + .object({ + eval: z.boolean().optional(), + inline: z.boolean().optional(), + allow: z.array(z.enum(sandboxAllowValues)).optional(), + sources: z + .object({ + connect: sources, + image: sources, + style: sources, + font: sources, + media: sources, + worker: sources, + child: sources, + }) + .optional(), + }) + .optional(), + }); + } +} diff --git a/src/cli/entrypoint/parser/index.ts b/src/cli/entrypoint/parser/index.ts index d7b6f5b..d24b629 100644 --- a/src/cli/entrypoint/parser/index.ts +++ b/src/cli/entrypoint/parser/index.ts @@ -4,6 +4,7 @@ export {default as ContentParser} from "./ContentParser"; export {default as PageParser} from "./PageParser"; export {default as PopupParser} from "./PopupParser"; export {default as RelayParser} from "./RelayParser"; +export {default as SandboxParser} from "./SandboxParser"; export {default as ServiceParser} from "./ServiceParser"; export {default as SidebarParser} from "./SidebarParser"; export {default as OffscreenParser} from "./OffscreenParser"; diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/class/init-class.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/class/init-class.ts new file mode 100644 index 0000000..c982a5f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/class/init-class.ts @@ -0,0 +1,24 @@ +import {defineSandbox} from "adnbn"; + +class ParserSandbox { + public constructor(public prefix: string) {} + + public parse(html: string): number { + return html.length; + } + + public normalize(html: string): string { + return this.prefix + html.trim(); + } + + private secret(): string { + return "hidden"; + } +} + +export default defineSandbox({ + name: "classContract", + init() { + return new ParserSandbox("safe:"); + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/api.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/api.ts new file mode 100644 index 0000000..d662f5a --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/api.ts @@ -0,0 +1,9 @@ +export class TemplateSandbox { + public render(template: string, values: Record): string { + return Object.entries(values).reduce((result, [key, value]) => result.replace(key, value), template); + } + + public count(html: string): number { + return html.length; + } +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/init-imported-class.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/init-imported-class.ts new file mode 100644 index 0000000..1bfd101 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/imported/init-imported-class.ts @@ -0,0 +1,10 @@ +import {defineSandbox} from "adnbn"; + +import {TemplateSandbox} from "./api"; + +export default defineSandbox({ + name: "importedClassContract", + init() { + return new TemplateSandbox(); + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/object/init-object.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/object/init-object.ts new file mode 100644 index 0000000..fee36c0 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/contracts/object/init-object.ts @@ -0,0 +1,16 @@ +import {defineSandbox} from "adnbn"; + +export default defineSandbox({ + name: "objectContract", + init() { + return { + parse(html: string): number { + return html.length; + }, + version: "1.0.0", + _private() { + return "hidden"; + }, + }; + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/allow-same-origin.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/allow-same-origin.ts new file mode 100644 index 0000000..ad49413 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/allow-same-origin.ts @@ -0,0 +1,11 @@ +import {defineSandbox} from "adnbn"; + +export default defineSandbox({ + name: "sameOrigin", + csp: { + allow: ["same-origin"], + }, + init() { + return {}; + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/invalid-name.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/invalid-name.ts new file mode 100644 index 0000000..171a338 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/invalid-name.ts @@ -0,0 +1,8 @@ +import {defineSandbox} from "adnbn"; + +export default defineSandbox({ + name: "invalid-name", + init() { + return {}; + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/unknown-source.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/unknown-source.ts new file mode 100644 index 0000000..79d2c20 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/invalid/unknown-source.ts @@ -0,0 +1,13 @@ +import {defineSandbox} from "adnbn"; + +export default defineSandbox({ + name: "unknownSource", + csp: { + sources: { + image: ["https://example.com"], + }, + }, + init() { + return {}; + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts new file mode 100644 index 0000000..2339d5d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts @@ -0,0 +1,43 @@ +import {Browser, defineSandbox, Mode, SandboxAllow, SandboxSource} from "adnbn"; + +export default defineSandbox({ + as: "unsafe-parser-frame", + title: "Unsafe parser", + template: "./template.html", + includeApp: ["admin"], + excludeApp: ["legacy"], + includeBrowser: [Browser.Chrome], + excludeBrowser: [Browser.Firefox], + mode: Mode.Production, + debug: true, + manifestVersion: 3, + name: "parser", + readyTimeout: 1000, + requestTimeout: 2000, + removeOnRequestTimeout: true, + csp: { + eval: true, + inline: false, + allow: [SandboxAllow.Forms, "modals"], + sources: { + connect: [SandboxSource.Self], + image: [SandboxSource.Self, "data:", "blob:"], + style: [SandboxSource.Self, SandboxSource.UnsafeInline], + font: ["'self'"], + media: ["blob:"], + worker: [SandboxSource.Blob], + child: [SandboxSource.Self], + }, + }, + scripts: "extra.js", + links: "extra.css", + metas: { + attributes: { + name: "sandbox-test", + content: "enabled", + }, + }, + init() { + return {}; + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/options.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/options.ts new file mode 100644 index 0000000..1ea35fc --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/options.ts @@ -0,0 +1,18 @@ +import {SandboxAllow, SandboxSource} from "adnbn"; + +enum LocalSandboxSource { + Data = "data:", +} + +export const sandboxName = "importedOptions"; +export const readyTimeout = 500; +export const requestTimeout = 1500; +export const sandboxCsp = { + eval: true, + inline: true, + allow: [SandboxAllow.Popups, "downloads"], + sources: { + image: [LocalSandboxSource.Data, SandboxSource.Blob], + connect: [SandboxSource.Self], + }, +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/sandbox.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/sandbox.ts new file mode 100644 index 0000000..6cbdca5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/imported-values/sandbox.ts @@ -0,0 +1,13 @@ +import {defineSandbox} from "adnbn"; + +import {readyTimeout, requestTimeout, sandboxCsp, sandboxName} from "./options"; + +export default defineSandbox({ + name: sandboxName, + readyTimeout, + requestTimeout, + csp: sandboxCsp, + init() { + return {}; + }, +}); diff --git a/src/cli/plugins/index.ts b/src/cli/plugins/index.ts index 40f0daf..4cfbb14 100644 --- a/src/cli/plugins/index.ts +++ b/src/cli/plugins/index.ts @@ -14,6 +14,7 @@ export {default as pluginOffscreen} from "./offscreen"; export {default as pluginPage} from "./page"; export {default as pluginPopup} from "./popup"; export {default as pluginPublic} from "./public"; +export {default as pluginSandbox} from "./sandbox"; export {default as pluginSidebar} from "./sidebar"; export {default as pluginTypescript, TypescriptConfig, FileBuilder, VendorDeclaration} from "./typescript"; export {default as pluginReact} from "./react"; diff --git a/src/cli/plugins/sandbox/Sandbox.ts b/src/cli/plugins/sandbox/Sandbox.ts new file mode 100644 index 0000000..1ecea51 --- /dev/null +++ b/src/cli/plugins/sandbox/Sandbox.ts @@ -0,0 +1,80 @@ +import {View} from "../view"; + +import {SandboxFinder, SandboxViewFinder} from "@cli/entrypoint"; +import {virtualSandboxModule} from "@cli/virtual"; + +import SandboxCsp from "./SandboxCsp"; + +import {EntrypointFile} from "@typing/entrypoint"; +import {SandboxEntrypointOptions, SandboxParameters} from "@typing/sandbox"; + +export type SandboxParametersMap = Record; + +export default class extends SandboxFinder { + protected _view?: View; + + protected _views?: SandboxViewFinder; + + public view(): View { + return (this._view ??= new View(this.config, this.views())); + } + + public views(): SandboxViewFinder { + return (this._views ??= new SandboxViewFinder(this.config, this)); + } + + public virtual(file: EntrypointFile): string { + const options = this._transport?.get(file)?.options; + + if (!options) { + throw new Error(`Sandbox options not found for "${file.file}"`); + } + + return virtualSandboxModule(file, options.name); + } + + public async parameters(): Promise { + const sandboxes: SandboxParametersMap = {}; + const files = await this.transport(); + const filenames = await this.views().getFilenames(); + + for (const [file, transport] of files) { + const {name, readyTimeout, requestTimeout, removeOnRequestTimeout} = transport.options; + const url = filenames.get(file); + + if (!url) { + throw new Error(`Sandbox filename not found for "${file.file}"`); + } + + sandboxes[name] = { + url, + readyTimeout, + requestTimeout, + removeOnRequestTimeout, + }; + } + + return sandboxes; + } + + public async sandboxes(): Promise { + return Object.values(await this.parameters()).map(({url}) => url); + } + + public async contentSecurityPolicy(): Promise { + const csp = new SandboxCsp(); + + for (const {options} of (await this.transport()).values()) { + csp.add(options.csp); + } + + return csp.build(); + } + + public clear(): this { + this._view = undefined; + this._views = undefined; + + return super.clear(); + } +} diff --git a/src/cli/plugins/sandbox/SandboxCsp.test.ts b/src/cli/plugins/sandbox/SandboxCsp.test.ts new file mode 100644 index 0000000..1ee7583 --- /dev/null +++ b/src/cli/plugins/sandbox/SandboxCsp.test.ts @@ -0,0 +1,37 @@ +import SandboxCsp from "./SandboxCsp"; + +import {SandboxAllow, SandboxSource} from "@typing/sandbox"; + +describe("SandboxCsp", () => { + test("builds the default sandbox policy", () => { + expect(new SandboxCsp().add().build()).toBe( + "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; child-src 'self';" + ); + }); + + test("merges allow tokens and sources", () => { + const policy = new SandboxCsp() + .add({ + eval: false, + inline: true, + allow: [SandboxAllow.Forms, "modals"], + sources: { + image: [SandboxSource.Self, SandboxSource.Data], + style: [SandboxSource.Self, SandboxSource.UnsafeInline], + }, + }) + .add({ + eval: true, + allow: [SandboxAllow.Popups], + sources: { + image: [SandboxSource.Blob], + }, + }) + .build(); + + expect(policy).toContain("sandbox allow-scripts allow-forms allow-modals allow-popups;"); + expect(policy).toContain("script-src 'self' 'unsafe-eval' 'unsafe-inline';"); + expect(policy).toContain("img-src 'self' data: blob:;"); + expect(policy).toContain("style-src 'self' 'unsafe-inline';"); + }); +}); diff --git a/src/cli/plugins/sandbox/SandboxCsp.ts b/src/cli/plugins/sandbox/SandboxCsp.ts new file mode 100644 index 0000000..7aec426 --- /dev/null +++ b/src/cli/plugins/sandbox/SandboxCsp.ts @@ -0,0 +1,81 @@ +import {SandboxAllow, SandboxContentSecurityPolicy, SandboxSource} from "@typing/sandbox"; + +const SourceDirectives: Record, string> = { + connect: "connect-src", + image: "img-src", + style: "style-src", + font: "font-src", + media: "media-src", + worker: "worker-src", + child: "child-src", +}; + +export default class SandboxCsp { + private eval = false; + private inline = false; + private readonly allow = new Set(); + private readonly sources: Map> = new Map(); + + public add(csp?: SandboxContentSecurityPolicy): this { + csp = { + eval: true, + inline: false, + allow: [], + sources: { + child: [SandboxSource.Self], + }, + ...csp, + }; + + if (csp.eval) { + this.eval = true; + } + + if (csp.inline) { + this.inline = true; + } + + for (const value of csp.allow || []) { + this.allow.add(value); + } + + for (const [key, values] of Object.entries(csp.sources || {})) { + const directive = SourceDirectives[key as keyof typeof SourceDirectives]; + + if (!directive || !values) { + continue; + } + + const source = this.sources.get(directive) ?? new Set(); + + for (const value of values) { + source.add(value); + } + + this.sources.set(directive, source); + } + + return this; + } + + public build(): string { + const sandbox = ["sandbox", "allow-scripts", ...Array.from(this.allow).map(value => `allow-${value}`)]; + const script = ["script-src", SandboxSource.Self]; + + if (this.eval) { + script.push("'unsafe-eval'" as SandboxSource); + } + + if (this.inline) { + script.push(SandboxSource.UnsafeInline); + } + + const directives = [sandbox, script]; + + for (const [directive, values] of this.sources) { + directives.push([directive, ...values]); + } + + return directives.map(parts => `${parts.join(" ")};`).join(" "); + } +} diff --git a/src/cli/plugins/sandbox/SandboxDeclaration.ts b/src/cli/plugins/sandbox/SandboxDeclaration.ts new file mode 100644 index 0000000..18f9b5f --- /dev/null +++ b/src/cli/plugins/sandbox/SandboxDeclaration.ts @@ -0,0 +1,9 @@ +import {TransportDeclaration, TransportDeclarationLayer} from "../typescript"; + +import {ReadonlyConfig} from "@typing/config"; + +export default class extends TransportDeclaration { + constructor(config: ReadonlyConfig) { + super(config, TransportDeclarationLayer.Sandbox); + } +} diff --git a/src/cli/plugins/sandbox/index.ts b/src/cli/plugins/sandbox/index.ts new file mode 100644 index 0000000..7699c56 --- /dev/null +++ b/src/cli/plugins/sandbox/index.ts @@ -0,0 +1,77 @@ +import {Configuration as RspackConfig, DefinePlugin, HtmlRspackPlugin, Plugins} from "@rspack/core"; +import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; + +import {definePlugin} from "@main/plugin"; +import {EntrypointPlugin} from "@cli/bundler"; + +import Sandbox, {SandboxParametersMap} from "./Sandbox"; +import SandboxDeclaration from "./SandboxDeclaration"; + +import {Command} from "@typing/app"; + +export default definePlugin(() => { + let sandbox: Sandbox; + let declaration: SandboxDeclaration; + + return { + name: "adnbn:sandbox", + startup: ({config}) => { + sandbox = new Sandbox(config); + declaration = new SandboxDeclaration(config); + }, + sandbox: () => sandbox.files(), + bundler: async ({config}) => { + declaration.dictionary(await sandbox.dictionary()).build(); + + let build = true; + + if (await sandbox.empty()) { + if (config.debug) { + console.info("Sandbox entries not found"); + } + + build = false; + } + + const plugins: Plugins = []; + let parameters: SandboxParametersMap = {}; + + if (build) { + parameters = await sandbox.parameters(); + + const plugin = EntrypointPlugin.from(await sandbox.view().entries()).virtual(file => + sandbox.virtual(file) + ); + + if (config.command === Command.Watch) { + plugin.watch(async () => { + declaration.dictionary(await sandbox.clear().dictionary()).build(); + + return sandbox.view().entries(); + }); + } + + const htmlPlugins = (await sandbox.view().html()).map(options => new HtmlRspackPlugin(options)); + const tagsPlugins = (await sandbox.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + + plugins.push(plugin, ...htmlPlugins, ...tagsPlugins); + } + + return { + plugins: [ + new DefinePlugin({ + __ADNBN_SANDBOX_PARAMETERS__: JSON.stringify(parameters), + }), + ...plugins, + ], + } satisfies RspackConfig; + }, + manifest: async ({manifest}) => { + if (await sandbox.exists()) { + manifest + .appendSandboxes(await sandbox.sandboxes()) + .setSandboxContentSecurityPolicy(await sandbox.contentSecurityPolicy()); + } + }, + }; +}); diff --git a/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts index 634fbbc..1aa048b 100644 --- a/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts +++ b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts @@ -8,6 +8,7 @@ export enum TransportDeclarationLayer { Service = "service", Offscreen = "offscreen", Relay = "relay", + Sandbox = "sandbox", } export default class = Record> extends FileBuilder { diff --git a/src/cli/resolvers/config.test.ts b/src/cli/resolvers/config.test.ts index add3a5e..e0f845b 100644 --- a/src/cli/resolvers/config.test.ts +++ b/src/cli/resolvers/config.test.ts @@ -19,6 +19,7 @@ jest.mock("../plugins", () => { pluginPopup: plugin("popup"), pluginPublic: plugin("public"), pluginReact: plugin("react"), + pluginSandbox: plugin("sandbox"), pluginSidebar: plugin("sidebar"), pluginStyle: plugin("style"), pluginTypescript: plugin("typescript"), diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 8a9d1db..32f2cf9 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -21,6 +21,7 @@ import { pluginPopup, pluginPublic, pluginReact, + pluginSandbox, pluginSidebar, pluginStyle, pluginTypescript, @@ -244,6 +245,7 @@ export default async (config: OptionalConfig): Promise => { mergeRelay = false, mergeService = false, mergeOffscreen = false, + mergeSandbox = false, commonChunks = true, artifactName = "[name]-[browser]-[mv]", assetsFilename = mode === Mode.Production && command === Command.Build && !debug @@ -316,6 +318,7 @@ export default async (config: OptionalConfig): Promise => { mergeRelay, mergeService, mergeOffscreen, + mergeSandbox, commonChunks, artifactName, assetsFilename, @@ -368,6 +371,7 @@ export default async (config: OptionalConfig): Promise => { pluginPublic(), pluginSidebar(), pluginOffscreen(), + pluginSandbox(), pluginPage(), pluginView(), pluginHtml(), diff --git a/src/cli/virtual/index.ts b/src/cli/virtual/index.ts index 519c5a3..d378923 100644 --- a/src/cli/virtual/index.ts +++ b/src/cli/virtual/index.ts @@ -5,6 +5,7 @@ import transport from "./transport.ts?raw"; import offscreen from "./offscreen.ts?raw"; import offscreenBackground from "./offscreen.background.ts?raw"; import relay from "./relay.ts?raw"; +import sandbox from "./sandbox.ts?raw"; import view from "./view.ts?raw"; import {inferEntrypointFramework} from "@cli/entrypoint"; @@ -12,7 +13,7 @@ import {inferEntrypointFramework} from "@cli/entrypoint"; import {PackageName} from "@typing/app"; import {EntrypointFile} from "@typing/entrypoint"; -const templates = {background, command, content, offscreen, relay, view, transport}; +const templates = {background, command, content, offscreen, relay, sandbox, view, transport}; const getEntryFramework = (file: EntrypointFile, entry: "content" | "view"): string => { return `${PackageName}/entry/${entry}/${inferEntrypointFramework(file)}`; @@ -62,6 +63,12 @@ export const virtualRelayModule = (file: EntrypointFile, name: string): string = .replace(`virtual:content-framework`, getEntryFramework(file, "content")); }; +export const virtualSandboxModule = (file: EntrypointFile, name: string): string => { + return getVirtualModule(file, "sandbox") + .replace("virtual:sandbox-name", name) + .replace(`virtual:view-framework`, getEntryFramework(file, "view")); +}; + export const virtualServiceModule = (file: EntrypointFile, name: string): string => { return getTransportModule(file, name, "service"); }; diff --git a/src/cli/virtual/sandbox.ts b/src/cli/virtual/sandbox.ts new file mode 100644 index 0000000..9842c3a --- /dev/null +++ b/src/cli/virtual/sandbox.ts @@ -0,0 +1,38 @@ +import type {SandboxUnresolvedDefinition} from "adnbn"; +import type {TransportType} from "adnbn/transport"; +import {isValidTransportDefinition, isValidTransportInitFunction} from "adnbn/entry/transport"; +import {Builder as SandboxBuilder} from "adnbn/entry/sandbox"; + +import {Builder as ViewBuilder} from "virtual:view-framework"; + +import * as module from "virtual:sandbox-entrypoint"; + +try { + const sandboxName = "virtual:sandbox-name"; + + const {default: defaultDefinition, ...otherDefinition} = module; + + let definition: SandboxUnresolvedDefinition = otherDefinition; + + if (isValidTransportDefinition(defaultDefinition)) { + definition = {...definition, ...defaultDefinition}; + } else if (isValidTransportInitFunction(defaultDefinition)) { + definition = {...definition, init: defaultDefinition}; + } + + const {init, main, name, ...options} = definition; + + new SandboxBuilder({ + name: sandboxName, + init, + main, + ...options, + }) + .view(new ViewBuilder(options)) + .build() + .catch(e => { + console.error("Failed to build sandbox: ", e); + }); +} catch (e) { + console.error("The sandbox crashed on startup:", e); +} diff --git a/src/cli/virtual/virtual.d.ts b/src/cli/virtual/virtual.d.ts index 9f9ac9c..4a6e6ec 100644 --- a/src/cli/virtual/virtual.d.ts +++ b/src/cli/virtual/virtual.d.ts @@ -67,6 +67,17 @@ declare module "virtual:relay-entrypoint" { export = module; } +declare module "virtual:sandbox-entrypoint" { + type SandboxDefinition = import("@typing/sandbox").SandboxDefinition; + + interface ModuleType extends SandboxDefinition { + default: SandboxDefinition | SandboxDefinition["init"] | undefined; + } + + const module: ModuleType; + export = module; +} + declare module "virtual:relay-framework" { type RelayUnresolvedDefinition = import("@typing/relay").RelayUnresolvedDefinition; @@ -119,6 +130,8 @@ declare module "adnbn" { import("@typing/offscreen").OffscreenUnresolvedDefinition; export type RelayUnresolvedDefinition = import("@typing/relay").RelayUnresolvedDefinition; + export type SandboxUnresolvedDefinition = + import("@typing/sandbox").SandboxUnresolvedDefinition; } declare module "adnbn/transport" { @@ -206,6 +219,20 @@ declare module "adnbn/entry/relay" { } } +declare module "adnbn/entry/sandbox" { + import type {SandboxDefinition, SandboxUnresolvedDefinition} from "@typing/sandbox"; + import type {TransportType} from "@typing/transport"; + import type {ViewBuilder} from "@typing/view"; + + export class Builder { + constructor(options: SandboxDefinition | SandboxUnresolvedDefinition); + + view(builder: ViewBuilder): this; + + build(): Promise; + } +} + declare module "adnbn/offscreen" { export class OffscreenBackground { build(): void | Promise; diff --git a/src/entry/sandbox/Builder.ts b/src/entry/sandbox/Builder.ts new file mode 100644 index 0000000..2932d22 --- /dev/null +++ b/src/entry/sandbox/Builder.ts @@ -0,0 +1,66 @@ +import TransportBuilder from "./TransportBuilder"; + +import Builder from "../core/Builder"; + +import {sandboxChannel} from "@sandbox/utils"; + +import { + SandboxGlobalAccess, + SandboxReadyMessage, + SandboxReadyMessageType, + SandboxUnresolvedDefinition, +} from "@typing/sandbox"; +import {TransportType} from "@typing/transport"; +import {ViewBuilder} from "@typing/view"; + +export default class extends Builder { + protected readonly _transport: TransportBuilder; + + protected _view?: ViewBuilder; + + private readonly name: string; + + constructor(definition: SandboxUnresolvedDefinition) { + super(); + + this.name = definition.name!; + this._transport = new TransportBuilder(definition); + } + + public view(view: ViewBuilder): this { + this._view = view; + + return this; + } + + public async build(): Promise { + await this.destroy(); + + globalThis[SandboxGlobalAccess] = true; + + await this._transport.build(); + await this._view?.build(); + + this.ready(); + } + + public async destroy(): Promise { + await this._transport.destroy(); + await this._view?.destroy(); + } + + private ready(): void { + if (window.parent === window) { + return; + } + + window.parent.postMessage( + { + type: SandboxReadyMessageType, + channel: sandboxChannel(this.name), + name: this.name, + } satisfies SandboxReadyMessage, + "*" + ); + } +} diff --git a/src/entry/sandbox/index.ts b/src/entry/sandbox/index.ts new file mode 100644 index 0000000..0a295e7 --- /dev/null +++ b/src/entry/sandbox/index.ts @@ -0,0 +1,2 @@ +export {default as Builder} from "./Builder"; +export {default as TransportBuilder} from "./TransportBuilder"; diff --git a/src/frame/ReadyFrame.test.ts b/src/frame/ReadyFrame.test.ts new file mode 100644 index 0000000..62a78b1 --- /dev/null +++ b/src/frame/ReadyFrame.test.ts @@ -0,0 +1,144 @@ +import ReadyFrame, {ReadyFrameParams} from "./ReadyFrame"; + +const wait = () => new Promise(resolve => setTimeout(resolve)); + +const ready = (frame: HTMLIFrameElement) => + window.dispatchEvent(new MessageEvent("message", {source: frame.contentWindow, data: {type: "ready"}})); + +const make = (frames: ReadyFrame, key = "a", overrides: Partial = {}) => + frames.make({ + key, + url: `${key}.html`, + isReady: (event, frame) => + event.source === frame.contentWindow && (event.data as {type?: string})?.type === "ready", + ...overrides, + }); + +describe("ReadyFrame", () => { + beforeEach(() => { + document.body.innerHTML = ""; + jest.useRealTimers(); + }); + + test("creates one iframe for concurrent calls and resolves both on ready", async () => { + const frames = new ReadyFrame(); + const first = make(frames); + const second = make(frames); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + expect(document.querySelectorAll("iframe")).toHaveLength(1); + + ready(frame); + + await expect(first).resolves.toBe(frame); + await expect(second).resolves.toBe(frame); + }); + + test("resolves only when the matcher accepts, not on load alone", async () => { + const frames = new ReadyFrame(); + const creation = make(frames); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + let resolved = false; + creation.then(() => (resolved = true)); + + frame.dispatchEvent(new Event("load")); + window.dispatchEvent(new MessageEvent("message", {source: frame.contentWindow, data: {type: "other"}})); + await wait(); + + expect(resolved).toBe(false); + + ready(frame); + + await expect(creation).resolves.toBe(frame); + }); + + test("reuses a ready frame for the same key", async () => { + const frames = new ReadyFrame(); + const first = make(frames); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + ready(frame); + await first; + + await expect(make(frames)).resolves.toBe(frame); + expect(document.querySelectorAll("iframe")).toHaveLength(1); + }); + + test("tracks independent frames per key", async () => { + const frames = new ReadyFrame(); + const a = make(frames, "a"); + const b = make(frames, "b"); + const [frameA, frameB] = Array.from(document.querySelectorAll("iframe")); + + expect(document.querySelectorAll("iframe")).toHaveLength(2); + + ready(frameA as HTMLIFrameElement); + ready(frameB as HTMLIFrameElement); + + await expect(Promise.all([a, b])).resolves.toEqual([frameA, frameB]); + }); + + test("rejects with the load-error message and removes the frame", async () => { + const frames = new ReadyFrame(); + const creation = make(frames, "a", {loadErrorMessage: () => "boom load"}); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + frame.dispatchEvent(new Event("error")); + + await expect(creation).rejects.toThrow("boom load"); + expect(document.querySelector("iframe")).toBeNull(); + }); + + test("rejects with the timeout message (carrying the loaded flag) and removes the frame", async () => { + jest.useFakeTimers(); + + const frames = new ReadyFrame(); + const creation = make(frames, "a", { + readyTimeout: 50, + readyTimeoutMessage: loaded => `timeout loaded=${loaded}`, + }); + const assertion = expect(creation).rejects.toThrow("timeout loaded=true"); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + frame.dispatchEvent(new Event("load")); + jest.advanceTimersByTime(50); + + await assertion; + expect(document.querySelector("iframe")).toBeNull(); + }); + + test("remove() drops the tracked frame", async () => { + const frames = new ReadyFrame(); + const creation = make(frames); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + ready(frame); + await creation; + + frames.remove("a"); + + expect(document.querySelector("iframe")).toBeNull(); + }); + + test("recreates the frame when it was removed from the DOM externally", async () => { + const frames = new ReadyFrame(); + const first = make(frames); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + ready(frame); + await first; + + frame.remove(); + + const second = make(frames); + const next = document.querySelector("iframe") as HTMLIFrameElement; + + expect(next).not.toBeNull(); + expect(next).not.toBe(frame); + + ready(next); + + await expect(second).resolves.toBe(next); + }); +}); diff --git a/src/frame/ReadyFrame.ts b/src/frame/ReadyFrame.ts new file mode 100644 index 0000000..91e9eb9 --- /dev/null +++ b/src/frame/ReadyFrame.ts @@ -0,0 +1,124 @@ +export interface ReadyFrameParams { + key: string; + url: string; + readyTimeout?: number; + isReady: (event: MessageEvent, frame: HTMLIFrameElement) => boolean; + readyTimeoutMessage?: (loaded: boolean, readyTimeout: number) => string; + loadErrorMessage?: () => string; +} + +const DefaultReadyTimeout = 10000; + +/** + * Creates and tracks a hidden iframe that announces readiness via `postMessage`, resolving once + * the injected `isReady` matcher accepts an inbound message — or rejecting on load error / ready + * timeout. Concurrent `make` calls for the same `key` share a single creation. + * + * The `isReady` matcher is the seam: each caller decides how to recognize and *trust* its own + * ready signal (a sandboxed frame has an opaque origin and matches by channel/name; a same-origin + * frame additionally matches by `event.origin`). Keeping that decision out here is what lets one + * deep module serve both the sandbox and the offscreen-fallback frames. + */ +export default class ReadyFrame { + private readonly frames: Map = new Map(); + + private readonly pending: Map> = new Map(); + + public async make(params: ReadyFrameParams): Promise { + if (typeof window === "undefined" || typeof document === "undefined" || !document.body) { + throw new Error("A frame can be created only from a page with DOM access."); + } + + const entry = this.frames.get(params.key); + + if (entry?.ready && entry.element.isConnected && entry.element.contentWindow) { + return entry.element; + } + + const pending = this.pending.get(params.key); + + if (pending) { + return pending; + } + + const creation = this.create(params); + + this.pending.set(params.key, creation); + + try { + return await creation; + } finally { + this.pending.delete(params.key); + } + } + + public remove(key: string): void { + this.frames.get(key)?.element.remove(); + this.frames.delete(key); + this.pending.delete(key); + } + + private create(params: ReadyFrameParams): Promise { + const {key, url, readyTimeout = DefaultReadyTimeout, isReady} = params; + + return new Promise((resolve, reject) => { + const existing = this.frames.get(key)?.element; + const frame = existing?.isConnected ? existing : document.createElement("iframe"); + + let loaded = false; + + const cleanup = () => { + clearTimeout(timeout); + window.removeEventListener("message", listener); + frame.onload = null; + frame.onerror = null; + }; + + const fail = (error: Error) => { + cleanup(); + frame.remove(); + this.frames.delete(key); + reject(error); + }; + + const listener = (event: MessageEvent) => { + if (!isReady(event, frame)) { + return; + } + + this.frames.set(key, {element: frame, ready: true}); + + cleanup(); + resolve(frame); + }; + + const timeout = setTimeout(() => { + fail( + new Error( + params.readyTimeoutMessage?.(loaded, readyTimeout) ?? + `Frame "${key}" was not ready within ${readyTimeout}ms.` + ) + ); + }, readyTimeout); + + frame.onload = () => { + loaded = true; + }; + + frame.onerror = () => { + fail(new Error(params.loadErrorMessage?.() ?? `Frame "${key}" failed to load: ${url}`)); + }; + + frame.hidden = true; + + window.addEventListener("message", listener); + + if (!frame.parentElement) { + frame.src = url; + document.body.appendChild(frame); + } + + this.frames.set(key, {element: frame, ready: false}); + }); + } +} diff --git a/src/frame/index.ts b/src/frame/index.ts new file mode 100644 index 0000000..528a679 --- /dev/null +++ b/src/frame/index.ts @@ -0,0 +1,2 @@ +export {default as ReadyFrame} from "./ReadyFrame"; +export type {ReadyFrameParams} from "./ReadyFrame"; diff --git a/src/main/index.ts b/src/main/index.ts index b36e643..c5637f8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -12,6 +12,7 @@ export * from "./page"; export * from "./plugin"; export * from "./popup"; export * from "./relay"; +export * from "./sandbox"; export * from "./service"; export * from "./sidebar"; export * from "./view"; diff --git a/src/main/sandbox.ts b/src/main/sandbox.ts new file mode 100644 index 0000000..d5b67e4 --- /dev/null +++ b/src/main/sandbox.ts @@ -0,0 +1,40 @@ +import {ProxySandbox} from "@sandbox/providers"; + +import {SandboxDefinition, SandboxParameters, SandboxUnresolvedDefinition} from "@typing/sandbox"; +import type {SandboxName, SandboxProxyTarget} from "@sandbox/index"; +import type {TransportType} from "@typing/transport"; + +export * from "@typing/sandbox"; + +export type SandboxAlias = string; + +export type SandboxMap = Map; + +export const defineSandbox = (options: SandboxDefinition): SandboxDefinition => { + return options; +}; + +export const getSandboxes = (): SandboxMap => { + const sandboxes: SandboxMap = new Map(); + + try { + // @ts-expect-error: __ADNBN_SANDBOX_PARAMETERS__ is a virtual variable generated by the sandbox plugin. + Object.entries(__ADNBN_SANDBOX_PARAMETERS__).forEach(([key, value]) => { + sandboxes.set(key, value); + }); + } catch (e) { + console.error("Failed getting sandboxes: ", e); + } + + return sandboxes; +}; + +export const getSandbox = (name: N): SandboxProxyTarget => { + const parameters = getSandboxes().get(name); + + if (!parameters) { + throw new Error(`Unable to get sandbox: ${name}`); + } + + return new ProxySandbox(name, parameters).get() as SandboxProxyTarget; +}; diff --git a/src/offscreen/OffscreenBridge.ts b/src/offscreen/OffscreenBridge.ts index 5e09b29..3c3f565 100644 --- a/src/offscreen/OffscreenBridge.ts +++ b/src/offscreen/OffscreenBridge.ts @@ -1,6 +1,7 @@ import {isBackground} from "@addon-core/browser"; import {Message} from "@message/providers"; +import {ReadyFrame} from "@frame/index"; import {OffscreenBridgeReadyMessageType} from "@typing/offscreen"; @@ -13,7 +14,7 @@ export default class OffscreenBridge { protected readonly readyTimeout = 10000; - private readonly framesReady: Map> = new Map(); + private readonly frames = new ReadyFrame(); private static instance?: OffscreenBridge; @@ -35,80 +36,17 @@ export default class OffscreenBridge { await this.message.send(this.key, parameters); } - protected apply({url}: CreateParameters): Promise { - const ready = this.framesReady.get(url); - - if (ready) { - return ready; - } - - const existing = this.getFrame(url); - - if (existing?.dataset.ready === "true") { - return Promise.resolve(); - } - - const creation = this.createFrame(url, existing).finally(() => { - this.framesReady.delete(url); - }); - - this.framesReady.set(url, creation); - - return creation; - } - - private createFrame(url: string, existing?: HTMLIFrameElement): Promise { - return new Promise((resolve, reject) => { - const iframe = existing ?? document.createElement("iframe"); - - const cleanup = () => { - clearTimeout(timeout); - window.removeEventListener("message", listener); - iframe.onerror = null; - }; - - const listener = (event: MessageEvent) => { - if (event.source !== iframe.contentWindow) { - return; - } - - if (event.origin !== location.origin) { - return; - } - - if (event.data?.type !== OffscreenBridgeReadyMessageType) { - return; - } - - iframe.dataset.ready = "true"; - - cleanup(); - resolve(); - }; - - const timeout = setTimeout(() => { - cleanup(); - iframe.remove(); - - reject(new Error(`Offscreen iframe "${url}" was not ready in time.`)); - }, this.readyTimeout); - - iframe.onerror = () => { - cleanup(); - iframe.remove(); - reject(new Error(`Offscreen iframe failed to load: ${url}`)); - }; - - window.addEventListener("message", listener); - - if (!existing) { - iframe.src = url; - document.body.appendChild(iframe); - } + protected async apply({url}: CreateParameters): Promise { + await this.frames.make({ + key: url, + url, + readyTimeout: this.readyTimeout, + isReady: (event, frame) => + event.source === frame.contentWindow && + event.origin === location.origin && + event.data?.type === OffscreenBridgeReadyMessageType, + readyTimeoutMessage: () => `Offscreen iframe "${url}" was not ready in time.`, + loadErrorMessage: () => `Offscreen iframe failed to load: ${url}`, }); } - - private getFrame(url: string): HTMLIFrameElement | undefined { - return Array.from(document.querySelectorAll("iframe")).find(iframe => iframe.getAttribute("src") === url); - } } diff --git a/src/sandbox/SandboxFrame.ts b/src/sandbox/SandboxFrame.ts new file mode 100644 index 0000000..2c5e37f --- /dev/null +++ b/src/sandbox/SandboxFrame.ts @@ -0,0 +1,38 @@ +import {sandboxChannel} from "./utils"; + +import {ReadyFrame} from "@frame/index"; + +import {SandboxParameters, SandboxReadyMessage, SandboxReadyMessageType} from "@typing/sandbox"; + +export default class SandboxFrame { + private readonly frames = new ReadyFrame(); + + public make(name: string, parameters: SandboxParameters): Promise { + const {url, readyTimeout} = parameters; + const channel = sandboxChannel(name); + + return this.frames.make({ + key: name, + url, + readyTimeout, + isReady: (event, frame) => { + if (event.source !== frame.contentWindow) { + return false; + } + + const data = event.data as Partial; + + return data?.type === SandboxReadyMessageType && data.channel === channel && data.name === name; + }, + readyTimeoutMessage: (loaded, timeout) => + loaded + ? `Sandbox "${name}" loaded but never signaled ready within ${timeout}ms. Ensure the sandbox entry runs and the manifest sandbox CSP allows its script.` + : `Sandbox "${name}" did not load "${url}" within ${timeout}ms. Ensure the page is listed in the manifest sandbox pages and is not blocked by CSP.`, + loadErrorMessage: () => `Sandbox "${name}" failed to load: ${url}`, + }); + } + + public remove(name: string): void { + this.frames.remove(name); + } +} diff --git a/src/sandbox/SandboxManager.ts b/src/sandbox/SandboxManager.ts new file mode 100644 index 0000000..3e51823 --- /dev/null +++ b/src/sandbox/SandboxManager.ts @@ -0,0 +1,11 @@ +import TransportManager from "@transport/TransportManager"; + +import {SandboxGlobalKey} from "@typing/sandbox"; + +import type {TransportManager as TransportManagerContract} from "@typing/transport"; + +export default class SandboxManager extends TransportManager { + public static getInstance(): TransportManagerContract { + return (globalThis[SandboxGlobalKey] ??= new SandboxManager()); + } +} diff --git a/src/sandbox/SandboxMessage.test.ts b/src/sandbox/SandboxMessage.test.ts new file mode 100644 index 0000000..1d325e7 --- /dev/null +++ b/src/sandbox/SandboxMessage.test.ts @@ -0,0 +1,81 @@ +import {nanoid} from "nanoid"; + +import SandboxMessage from "./SandboxMessage"; +import {SandboxMemory} from "./ports"; + +import type {SandboxParameters} from "@typing/sandbox"; + +describe("SandboxMessage", () => { + test("round-trips a request to the sandbox handler and resolves the response", async () => { + const [hostPort, guestPort] = SandboxMemory.pair(); + const host = new SandboxMessage("parser", hostPort, {requestTimeout: 1000}); + const guest = new SandboxMessage("parser", guestPort); + + let receivedPath: string | undefined; + + guest.watch(({path, args}) => { + receivedPath = path; + + return (args[0] as string).length; + }); + + await expect(host.send({path: "parse", args: ["

Hello

"]})).resolves.toBe(12); + expect(receivedPath).toBe("parse"); + }); + + test("propagates handler errors back to the caller", async () => { + const [hostPort, guestPort] = SandboxMemory.pair(); + const host = new SandboxMessage("parser", hostPort, {requestTimeout: 1000}); + const guest = new SandboxMessage("parser", guestPort); + + guest.watch(() => { + throw new TypeError("bad html"); + }); + + const error = await host.send({path: "parse", args: []}).catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(TypeError); + expect((error as Error).message).toBe("bad html"); + }); + + test("rejects when no response arrives before requestTimeout", async () => { + const [hostPort] = SandboxMemory.pair(); // nothing watches the guest end + + const host = new SandboxMessage("parser", hostPort, {requestTimeout: 10}); + + await expect(host.send({path: "parse", args: []})).rejects.toThrow('Sandbox "parser" request'); + }); + + test("concurrent requests share the channel and all resolve", async () => { + // nanoid is globally mocked to a constant in tests; give each request a unique id. + let counter = 0; + jest.mocked(nanoid).mockImplementation(() => `req-${++counter}`); + + const [hostPort, guestPort] = SandboxMemory.pair(); + const host = new SandboxMessage("parser", hostPort, {requestTimeout: 1000}); + const guest = new SandboxMessage("parser", guestPort); + + guest.watch(({args}) => args[0]); + + await expect( + Promise.all([ + host.send({path: "echo", args: [1]}), + host.send({path: "echo", args: [2]}), + host.send({path: "echo", args: [3]}), + ]) + ).resolves.toEqual([1, 2, 3]); + }); + + test("caches one host channel per name and re-creates after dispose", () => { + const params: SandboxParameters = {url: "sandbox.html"}; + + const first = SandboxMessage.for("cached", params); + const second = SandboxMessage.for("cached", params); + + expect(second).toBe(first); + + first.dispose(); + + expect(SandboxMessage.for("cached", params)).not.toBe(first); + }); +}); diff --git a/src/sandbox/SandboxMessage.ts b/src/sandbox/SandboxMessage.ts new file mode 100644 index 0000000..071e2d1 --- /dev/null +++ b/src/sandbox/SandboxMessage.ts @@ -0,0 +1,179 @@ +import {nanoid} from "nanoid"; + +import {sandboxChannel} from "./utils"; +import {SandboxHost} from "./ports"; + +import {restoreError, serializeError} from "@message/error"; +import { + SandboxParameters, + SandboxPort, + SandboxRequestMessage, + SandboxRequestMessageType, + SandboxResponseMessage, + SandboxResponseMessageType, +} from "@typing/sandbox"; +import type {MessageSender} from "@typing/message"; +import type {TransportMessage, TransportMessageData} from "@typing/transport"; + +type Pending = { + resolve: (value: any) => void; + reject: (reason?: any) => void; + timeout: ReturnType; +}; + +const DefaultRequestTimeout = 30000; + +/** + * The standard transport channel (`send`/`watch`) for the sandbox layer, implemented over + * `window.postMessage` instead of the chrome.runtime `Message` provider. All request/response + * correlation (requestId, pending map, timeouts, error serialize/restore) lives here, behind + * the standard interface; the raw wire sits below in a `SandboxPort`. + */ +export default class SandboxMessage implements TransportMessage { + private static readonly hosts: Map = new Map(); + + private readonly channel: string; + + private readonly pending: Map = new Map(); + + private listenUnsubscribe?: () => void; + + private watchUnsubscribe?: () => void; + + constructor( + private readonly name: string, + private readonly port: SandboxPort, + private readonly parameters: Partial = {} + ) { + this.channel = sandboxChannel(name); + } + + /** + * Host-side channel, cached per name. One cached instance means one peer listener per + * sandbox no matter how many times `getSandbox()` is called — this is the leak fix. + */ + public static for(name: string, parameters: SandboxParameters): SandboxMessage { + let message = this.hosts.get(name); + + if (!message) { + message = new SandboxMessage(name, new SandboxHost(name, parameters), parameters); + + this.hosts.set(name, message); + } + + return message; + } + + public async send(data: TransportMessageData): Promise { + await this.port.connect(); + + this.listen(); + + const requestId = nanoid(); + const request: SandboxRequestMessage = { + type: SandboxRequestMessageType, + channel: this.channel, + name: this.name, + requestId, + path: data.path, + args: data.args, + }; + + const requestTimeout = this.parameters.requestTimeout ?? DefaultRequestTimeout; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(requestId); + + if (this.parameters.removeOnRequestTimeout && this.pending.size === 0) { + this.dispose(); + } + + reject(new Error(`Sandbox "${this.name}" request "${requestId}" timed out.`)); + }, requestTimeout); + + this.pending.set(requestId, {resolve, reject, timeout}); + + this.port.post(request); + }); + } + + public watch(handler: (data: TransportMessageData, sender: MessageSender) => any): () => void { + if (this.watchUnsubscribe) { + return this.watchUnsubscribe; + } + + const unsubscribe = this.port.subscribe(async (message, sender) => { + if (message.type !== SandboxRequestMessageType) { + return; + } + + try { + const payload = await handler({path: message.path, args: message.args ?? []}, sender); + + this.respond({requestId: message.requestId, ok: true, payload}); + } catch (error) { + this.respond({requestId: message.requestId, ok: false, error: serializeError(error)}); + } + }); + + this.watchUnsubscribe = unsubscribe; + + return unsubscribe; + } + + public dispose(): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error(`Sandbox "${this.name}" was disposed.`)); + } + + this.pending.clear(); + + this.listenUnsubscribe?.(); + this.listenUnsubscribe = undefined; + + this.watchUnsubscribe?.(); + this.watchUnsubscribe = undefined; + + this.port.dispose(); + + SandboxMessage.hosts.delete(this.name); + } + + private listen(): void { + if (this.listenUnsubscribe) { + return; + } + + this.listenUnsubscribe = this.port.subscribe(message => { + if (message.type !== SandboxResponseMessageType) { + return; + } + + const pending = this.pending.get(message.requestId); + + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + this.pending.delete(message.requestId); + + if (message.ok) { + pending.resolve(message.payload); + } else { + pending.reject(restoreError(message.error)); + } + }); + } + + private respond(response: Omit): void { + this.port.post({ + type: SandboxResponseMessageType, + channel: this.channel, + name: this.name, + ...response, + }); + } +} diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts new file mode 100644 index 0000000..64c55b1 --- /dev/null +++ b/src/sandbox/index.ts @@ -0,0 +1,11 @@ +import {ProxySandbox, RegisterSandbox} from "./providers"; + +import type {DeepAsyncProxy} from "@typing/helpers"; + +export {ProxySandbox, RegisterSandbox}; + +export interface SandboxRegistry {} + +export type SandboxName = Extract; + +export type SandboxProxyTarget = DeepAsyncProxy; diff --git a/src/sandbox/ports/SandboxHost.ts b/src/sandbox/ports/SandboxHost.ts new file mode 100644 index 0000000..1596e47 --- /dev/null +++ b/src/sandbox/ports/SandboxHost.ts @@ -0,0 +1,79 @@ +import {sandboxChannel, sandboxSender} from "../utils"; +import SandboxFrame from "../SandboxFrame"; + +import type {SandboxEnvelope, SandboxParameters, SandboxPort} from "@typing/sandbox"; +import type {MessageSender} from "@typing/message"; + +/** + * Host-side `SandboxPort`: runs in an extension page, owns the sandbox iframe. + * `connect` creates the iframe and awaits its ready handshake (via `SandboxFrame`); + * `post` targets the iframe's `contentWindow`; `subscribe` attaches a single `message` + * listener filtered to that frame. + */ +export default class SandboxHost implements SandboxPort { + private readonly frames = new SandboxFrame(); + + private readonly channel: string; + + private frame?: HTMLIFrameElement; + + private unsubscribe?: () => void; + + constructor( + private readonly name: string, + private readonly parameters: SandboxParameters + ) { + this.channel = sandboxChannel(name); + } + + public async connect(): Promise { + this.frame = await this.frames.make(this.name, this.parameters); + } + + public post(message: SandboxEnvelope): void { + const target = this.frame?.contentWindow; + + if (!target) { + throw new Error(`Sandbox "${this.name}" is not available.`); + } + + // Sandboxed iframes have an opaque origin, so no concrete targetOrigin is possible. + // Inbound messages are trusted by channel + name + requestId, never by origin. + target.postMessage(message, "*"); + } + + public subscribe(onMessage: (message: SandboxEnvelope, sender: MessageSender) => void): () => void { + if (this.unsubscribe) { + return this.unsubscribe; + } + + const listener = (event: MessageEvent) => { + if (event.source !== this.frame?.contentWindow) { + return; + } + + const data = event.data as Partial; + + if (data?.channel !== this.channel || data.name !== this.name) { + return; + } + + onMessage(data as SandboxEnvelope, sandboxSender()); + }; + + window.addEventListener("message", listener); + + this.unsubscribe = () => { + window.removeEventListener("message", listener); + this.unsubscribe = undefined; + }; + + return this.unsubscribe; + } + + public dispose(): void { + this.unsubscribe?.(); + this.frames.remove(this.name); + this.frame = undefined; + } +} diff --git a/src/sandbox/ports/SandboxInner.ts b/src/sandbox/ports/SandboxInner.ts new file mode 100644 index 0000000..c5213f6 --- /dev/null +++ b/src/sandbox/ports/SandboxInner.ts @@ -0,0 +1,62 @@ +import {sandboxChannel, sandboxSender} from "../utils"; + +import type {SandboxEnvelope, SandboxPort} from "@typing/sandbox"; +import type {MessageSender} from "@typing/message"; + +/** + * Sandbox-side `SandboxPort`: runs inside the iframe. There is no frame to create, so + * `connect` resolves immediately (the ready handshake is posted by the sandbox builder); + * `post` targets `window.parent`; `subscribe` listens for messages from the parent. + */ +export default class SandboxInner implements SandboxPort { + private readonly channel: string; + + private unsubscribe?: () => void; + + constructor(private readonly name: string) { + this.channel = sandboxChannel(name); + } + + public connect(): Promise { + return Promise.resolve(); + } + + public post(message: SandboxEnvelope): void { + // Opaque sandbox origin: no concrete targetOrigin is possible; the host trusts + // messages by channel + name + requestId, never by origin. + window.parent.postMessage(message, "*"); + } + + public subscribe(onMessage: (message: SandboxEnvelope, sender: MessageSender) => void): () => void { + if (this.unsubscribe) { + return this.unsubscribe; + } + + const listener = (event: MessageEvent) => { + if (event.source !== window.parent) { + return; + } + + const data = event.data as Partial; + + if (data?.channel !== this.channel || data.name !== this.name) { + return; + } + + onMessage(data as SandboxEnvelope, sandboxSender()); + }; + + window.addEventListener("message", listener); + + this.unsubscribe = () => { + window.removeEventListener("message", listener); + this.unsubscribe = undefined; + }; + + return this.unsubscribe; + } + + public dispose(): void { + this.unsubscribe?.(); + } +} diff --git a/src/sandbox/ports/SandboxMemory.ts b/src/sandbox/ports/SandboxMemory.ts new file mode 100644 index 0000000..874200d --- /dev/null +++ b/src/sandbox/ports/SandboxMemory.ts @@ -0,0 +1,59 @@ +import type {SandboxEnvelope, SandboxPort} from "@typing/sandbox"; +import type {MessageSender} from "@typing/message"; + +const MemorySender = {url: "memory:", origin: "memory:"} as MessageSender; + +/** + * In-memory `SandboxPort` used in tests. A linked pair of endpoints delivers each posted + * envelope to the other end asynchronously (microtask), mimicking `postMessage` without a + * DOM. Lets `SandboxMessage`'s correlation be tested end-to-end and deterministically. + */ +class MemoryPort implements SandboxPort { + private peer?: MemoryPort; + + private readonly listeners: Set<(message: SandboxEnvelope, sender: MessageSender) => void> = new Set(); + + public link(peer: MemoryPort): void { + this.peer = peer; + } + + public connect(): Promise { + return Promise.resolve(); + } + + public post(message: SandboxEnvelope): void { + const peer = this.peer; + + if (!peer) { + return; + } + + queueMicrotask(() => { + for (const listener of peer.listeners) { + listener(message, MemorySender); + } + }); + } + + public subscribe(onMessage: (message: SandboxEnvelope, sender: MessageSender) => void): () => void { + this.listeners.add(onMessage); + + return () => this.listeners.delete(onMessage); + } + + public dispose(): void { + this.listeners.clear(); + } +} + +export default class SandboxMemory { + public static pair(): [SandboxPort, SandboxPort] { + const host = new MemoryPort(); + const guest = new MemoryPort(); + + host.link(guest); + guest.link(host); + + return [host, guest]; + } +} diff --git a/src/sandbox/ports/index.ts b/src/sandbox/ports/index.ts new file mode 100644 index 0000000..57974cd --- /dev/null +++ b/src/sandbox/ports/index.ts @@ -0,0 +1,3 @@ +export {default as SandboxHost} from "./SandboxHost"; +export {default as SandboxInner} from "./SandboxInner"; +export {default as SandboxMemory} from "./SandboxMemory"; diff --git a/src/sandbox/providers/ProxySandbox.ts b/src/sandbox/providers/ProxySandbox.ts new file mode 100644 index 0000000..e960682 --- /dev/null +++ b/src/sandbox/providers/ProxySandbox.ts @@ -0,0 +1,25 @@ +import ProxyTransport from "@transport/ProxyTransport"; + +import SandboxManager from "../SandboxManager"; +import SandboxMessage from "../SandboxMessage"; + +import type {DeepAsyncProxy} from "@typing/helpers"; +import type {SandboxParameters} from "@typing/sandbox"; +import type {TransportDictionary, TransportManager, TransportName} from "@typing/transport"; + +export default class> extends ProxyTransport { + constructor( + name: N, + private readonly parameters: SandboxParameters + ) { + super(name); + } + + protected manager(): TransportManager { + return SandboxManager.getInstance(); + } + + protected apply(args: any[], path?: string): Promise { + return SandboxMessage.for(this.name, this.parameters).send({path, args}); + } +} diff --git a/src/sandbox/providers/RegisterSandbox.ts b/src/sandbox/providers/RegisterSandbox.ts new file mode 100644 index 0000000..6e3f881 --- /dev/null +++ b/src/sandbox/providers/RegisterSandbox.ts @@ -0,0 +1,37 @@ +import RegisterTransport from "@transport/RegisterTransport"; + +import {isSandbox} from "../utils"; +import SandboxManager from "../SandboxManager"; +import SandboxMessage from "../SandboxMessage"; +import {SandboxInner} from "../ports"; + +import type {TransportDictionary, TransportName, TransportReceiver} from "@typing/transport"; + +export default class< + N extends TransportName, + T extends object = TransportDictionary[N], + A extends any[] = [], +> extends RegisterTransport { + constructor( + name: N, + protected readonly init: (...args: A) => T + ) { + super(name, init); + } + + protected message(): TransportReceiver { + return new SandboxMessage(this.name, new SandboxInner(this.name)); + } + + protected manager() { + return SandboxManager.getInstance(); + } + + public get(): T { + if (!isSandbox()) { + throw new Error(`Sandbox "${this.name}" can be getting only from sandbox context.`); + } + + return super.get(); + } +} diff --git a/src/sandbox/providers/Sandbox.test.ts b/src/sandbox/providers/Sandbox.test.ts new file mode 100644 index 0000000..4072b20 --- /dev/null +++ b/src/sandbox/providers/Sandbox.test.ts @@ -0,0 +1,132 @@ +import SandboxFrame from "../SandboxFrame"; +import SandboxMessage from "../SandboxMessage"; +import RegisterSandbox from "./RegisterSandbox"; +import {sandboxChannel} from "../utils"; + +import {SandboxReadyMessageType, SandboxRequestMessageType, SandboxResponseMessageType} from "@typing/sandbox"; + +const ready = (name: string, frame: HTMLIFrameElement) => { + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + data: { + type: SandboxReadyMessageType, + channel: sandboxChannel(name), + name, + }, + }) + ); +}; + +const waitFor = async (condition: () => boolean, tries = 100): Promise => { + for (let i = 0; i < tries; i++) { + if (condition()) { + return; + } + + await Promise.resolve(); + } + + throw new Error("waitFor: condition was not met in time"); +}; + +describe("Sandbox host runtime", () => { + beforeEach(() => { + document.body.innerHTML = ""; + jest.useRealTimers(); + }); + + test("creates one iframe for parallel first calls", async () => { + const frames = new SandboxFrame(); + const first = frames.make("parser", {url: "sandbox.html"}); + const second = frames.make("parser", {url: "sandbox.html"}); + const frame = document.querySelector("iframe") as HTMLIFrameElement; + + expect(document.querySelectorAll("iframe")).toHaveLength(1); + + ready("parser", frame); + + await expect(first).resolves.toBe(frame); + await expect(second).resolves.toBe(frame); + }); + + test("host channel posts over the iframe and resolves the matching response", async () => { + const message = SandboxMessage.for("parser", {url: "sandbox.html", requestTimeout: 1000}); + const send = message.send({path: "parse", args: ["

Hi

"]}); + + const frame = document.querySelector("iframe") as HTMLIFrameElement; + const postMessage = jest.fn(); + Object.defineProperty(frame.contentWindow, "postMessage", {configurable: true, value: postMessage}); + + ready("parser", frame); + + await waitFor(() => postMessage.mock.calls.length > 0); + const request = postMessage.mock.calls[0][0]; + + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + data: { + type: SandboxResponseMessageType, + channel: sandboxChannel("parser"), + name: "parser", + requestId: request.requestId, + ok: true, + payload: 2, + }, + }) + ); + + await expect(send).resolves.toBe(2); + + message.dispose(); + }); + + test("rebuild (register → destroy → register) handles each request once", async () => { + const handler = jest.fn(() => 1); + + const first = new RegisterSandbox("rebuilt", () => ({run: handler})); + first.register(); + first.destroy(); + + const second = new RegisterSandbox("rebuilt", () => ({run: handler})); + second.register(); + + window.dispatchEvent( + new MessageEvent("message", { + source: window.parent, + data: { + type: SandboxRequestMessageType, + channel: sandboxChannel("rebuilt"), + name: "rebuilt", + requestId: "rebuilt-req", + path: "run", + args: [], + }, + }) + ); + + await Promise.resolve(); + await Promise.resolve(); + + expect(handler).toHaveBeenCalledTimes(1); + + second.destroy(); + }); + + test("ready timeout reports that the frame loaded but never signaled ready", async () => { + jest.useFakeTimers(); + + const creation = new SandboxFrame().make("parser", {url: "sandbox.html", readyTimeout: 100}); + const assertion = expect(creation).rejects.toThrow("never signaled ready"); + + const frame = document.querySelector("iframe") as HTMLIFrameElement; + frame.dispatchEvent(new Event("load")); + + jest.advanceTimersByTime(100); + + await assertion; + + jest.useRealTimers(); + }); +}); diff --git a/src/sandbox/providers/index.ts b/src/sandbox/providers/index.ts new file mode 100644 index 0000000..5f978ab --- /dev/null +++ b/src/sandbox/providers/index.ts @@ -0,0 +1,2 @@ +export {default as ProxySandbox} from "./ProxySandbox"; +export {default as RegisterSandbox} from "./RegisterSandbox"; diff --git a/src/sandbox/utils.ts b/src/sandbox/utils.ts new file mode 100644 index 0000000..2695ec2 --- /dev/null +++ b/src/sandbox/utils.ts @@ -0,0 +1,9 @@ +import {SandboxGlobalAccess, SandboxNamespace} from "@typing/sandbox"; +import type {MessageSender} from "@typing/message"; + +export const isSandbox = (): boolean => globalThis[SandboxGlobalAccess] === true; + +export const sandboxChannel = (name: string): string => `${SandboxNamespace}:${name}`; + +export const sandboxSender = (): MessageSender => + ({url: document.location.href, origin: document.location.origin}) as MessageSender; diff --git a/src/types/config.ts b/src/types/config.ts index d9ab855..683974f 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -539,6 +539,14 @@ export interface Config { */ mergeOffscreen: boolean; + /** + * Flag indicating whether to merge sandbox files from App and Shared directories. + * When `true`, sandbox files from both directories will be combined. + * + * @default false + */ + mergeSandbox: boolean; + /** * Path to the directory containing public assets to be copied into the build output. * Must be relative to the project root and cannot be "." (the project root itself). diff --git a/src/types/entrypoint.ts b/src/types/entrypoint.ts index a9755ba..251234b 100644 --- a/src/types/entrypoint.ts +++ b/src/types/entrypoint.ts @@ -15,6 +15,7 @@ export enum EntrypointType { Popup = "popup", Sidebar = "sidebar", Offscreen = "offscreen", + Sandbox = "sandbox", } export interface EntrypointOptions { diff --git a/src/types/plugin.ts b/src/types/plugin.ts index bd78c41..2b84ece 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -84,6 +84,7 @@ export interface Plugin extends PluginName { page?: PluginHandler; popup?: PluginHandler; relay?: PluginHandler; + sandbox?: PluginHandler; service?: PluginHandler; sidebar?: PluginHandler; offscreen?: PluginHandler; @@ -101,7 +102,7 @@ export type PluginHandlerKeys = keyof Omit; export type PluginEntrypointKeys = keyof Pick< Plugin, - "background" | "command" | "content" | "page" | "popup" | "relay" | "service" | "sidebar" | "offscreen" + "background" | "command" | "content" | "page" | "popup" | "relay" | "sandbox" | "service" | "sidebar" | "offscreen" >; export type PluginAssetKeys = keyof Pick; diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts new file mode 100644 index 0000000..6240acf --- /dev/null +++ b/src/types/sandbox.ts @@ -0,0 +1,120 @@ +import {TransportConfig, TransportDefinition, TransportType} from "@typing/transport"; +import {ViewOptions} from "@typing/view"; +import {Awaiter} from "@typing/helpers"; +import {MessageError, MessageSender} from "@typing/message"; + +export const SandboxGlobalKey = "adnbnSandbox"; +export const SandboxGlobalAccess = "adnbnSandboxAccess"; +export const SandboxNamespace = "adnbn:sandbox"; +export const SandboxReadyMessageType = `${SandboxNamespace}:ready`; +export const SandboxRequestMessageType = `${SandboxNamespace}:request`; +export const SandboxResponseMessageType = `${SandboxNamespace}:response`; + +export enum SandboxAllow { + Forms = "forms", + Popups = "popups", + Modals = "modals", + Downloads = "downloads", + PointerLock = "pointer-lock", + TopNavigationByUserActivation = "top-navigation-by-user-activation", +} + +export enum SandboxSource { + Self = "'self'", + None = "'none'", + Data = "data:", + Blob = "blob:", + UnsafeInline = "'unsafe-inline'", +} + +export interface SandboxContentSecurityPolicySources { + connect?: Array; + image?: Array; + style?: Array; + font?: Array; + media?: Array; + worker?: Array; + child?: Array; +} + +export interface SandboxContentSecurityPolicy { + eval?: boolean; + inline?: boolean; + allow?: Array; + sources?: SandboxContentSecurityPolicySources; +} + +export interface SandboxConfig extends TransportConfig { + csp?: SandboxContentSecurityPolicy; + readyTimeout?: number; + requestTimeout?: number; + removeOnRequestTimeout?: boolean; +} + +export type SandboxOptions = SandboxConfig & ViewOptions; + +export type SandboxEntrypointOptions = Partial; + +export type SandboxMainHandler = ( + sandbox: T, + options: SandboxEntrypointOptions +) => Awaiter; + +export interface SandboxDefinition + extends TransportDefinition, SandboxEntrypointOptions { + main?: SandboxMainHandler; +} + +export type SandboxUnresolvedDefinition = Partial>; + +export type SandboxParameters = {url: string} & Pick< + SandboxConfig, + "readyTimeout" | "requestTimeout" | "removeOnRequestTimeout" +>; + +export interface SandboxReadyMessage { + type: typeof SandboxReadyMessageType; + channel: string; + name: string; +} + +export interface SandboxRequestMessage { + type: typeof SandboxRequestMessageType; + channel: string; + name: string; + requestId: string; + path?: string; + args: any[]; +} + +export interface SandboxResponseMessage { + type: typeof SandboxResponseMessageType; + channel: string; + name: string; + requestId: string; + ok: boolean; + payload?: any; + error?: MessageError; +} + +export type SandboxEnvelope = SandboxRequestMessage | SandboxResponseMessage; + +/** + * Internal seam for the sandbox wire. Lives below `SandboxMessage`, invisible to callers. + * + * - `connect` establishes the channel (host: create the iframe and await the ready + * handshake; sandbox/in-memory: resolves immediately). Must be idempotent — concurrent + * callers share one connection. + * - `post` sends one already-built envelope to the peer. + * - `subscribe` attaches exactly one underlying listener and returns its remover. + * - `dispose` tears the channel down (remove listener, drop the iframe). + */ +export interface SandboxPort { + connect(): Promise; + + post(message: SandboxEnvelope): void; + + subscribe(onMessage: (message: SandboxEnvelope, sender: MessageSender) => void): () => void; + + dispose(): void; +} diff --git a/tsconfig.json b/tsconfig.json index 6c5c495..01df14c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,11 +24,13 @@ "paths": { "@cli/*": ["cli/*"], "@entry/*": ["entry/*"], + "@frame/*": ["frame/*"], "@locale/*": ["locale/*"], "@main/*": ["main/*"], "@message/*": ["message/*"], "@offscreen/*": ["offscreen/*"], "@relay/*": ["relay/*"], + "@sandbox/*": ["sandbox/*"], "@service/*": ["service/*"], "@storage/*": ["storage/*"], "@transport/*": ["transport/*"], From 1db73c9446ecfe36e6c675216c3fb63341c02c8a Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sat, 23 May 2026 19:14:47 +0300 Subject: [PATCH 06/10] fix(style): add support for merging Sass and CSS with PostCSS and improve style handling --- package-lock.json | 36 +++- package.json | 2 + src/cli/plugins/{style.ts => style/index.ts} | 41 ++-- .../tests/fixtures/block-atrules/app.scss | 5 + .../tests/fixtures/block-atrules/shared.scss | 5 + .../style/tests/fixtures/charset/app.scss | 5 + .../style/tests/fixtures/charset/shared.scss | 6 + .../style/tests/fixtures/comments/app.scss | 6 + .../style/tests/fixtures/comments/shared.scss | 6 + .../fixtures/conflicting-prelude/app.scss | 5 + .../fixtures/conflicting-prelude/shared.scss | 5 + .../style/tests/fixtures/css-prelude/app.scss | 5 + .../tests/fixtures/css-prelude/shared.scss | 5 + .../tests/fixtures/duplicate-body/app.scss | 5 + .../tests/fixtures/duplicate-body/shared.scss | 5 + .../tests/fixtures/duplicate-prelude/app.scss | 5 + .../fixtures/duplicate-prelude/shared.scss | 5 + .../style/tests/fixtures/forward/app.scss | 5 + .../style/tests/fixtures/forward/shared.scss | 5 + .../tests/fixtures/layer-statement/app.scss | 5 + .../fixtures/layer-statement/shared.scss | 5 + .../tests/fixtures/module-order/app.scss | 5 + .../tests/fixtures/module-order/shared.scss | 5 + .../style/tests/fixtures/namespace/app.scss | 5 + .../tests/fixtures/namespace/shared.scss | 5 + .../style/tests/fixtures/variables/app.scss | 6 + .../tests/fixtures/variables/shared.scss | 5 + src/cli/plugins/style/utils.test.ts | 179 ++++++++++++++++++ src/cli/plugins/style/utils.ts | 116 ++++++++++++ 29 files changed, 472 insertions(+), 26 deletions(-) rename src/cli/plugins/{style.ts => style/index.ts} (77%) create mode 100644 src/cli/plugins/style/tests/fixtures/block-atrules/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/block-atrules/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/charset/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/charset/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/comments/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/comments/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/conflicting-prelude/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/conflicting-prelude/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/css-prelude/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/css-prelude/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/duplicate-body/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/duplicate-body/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/duplicate-prelude/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/duplicate-prelude/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/forward/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/forward/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/layer-statement/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/layer-statement/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/module-order/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/module-order/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/namespace/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/namespace/shared.scss create mode 100644 src/cli/plugins/style/tests/fixtures/variables/app.scss create mode 100644 src/cli/plugins/style/tests/fixtures/variables/shared.scss create mode 100644 src/cli/plugins/style/utils.test.ts create mode 100644 src/cli/plugins/style/utils.ts diff --git a/package-lock.json b/package-lock.json index e9f63a2..857d5ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,8 @@ "mini-css-extract-plugin": "^2.9.2", "nanoid": "^5.1.4", "pluralize": "^8.0.0", + "postcss": "^8.5.15", + "postcss-scss": "^4.0.9", "rspack-plugin-virtual-module": "^1.0.1", "sass": "^1.83.4", "sass-loader": "^16.0.4", @@ -16312,9 +16314,9 @@ } }, "node_modules/postcss": { - "version": "8.5.13", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", - "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -16331,7 +16333,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -16441,6 +16443,32 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-scss": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", + "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss-scss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.4.29" + } + }, "node_modules/postcss-selector-parser": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", diff --git a/package.json b/package.json index 6b4d294..aef9692 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,8 @@ "mini-css-extract-plugin": "^2.9.2", "nanoid": "^5.1.4", "pluralize": "^8.0.0", + "postcss": "^8.5.15", + "postcss-scss": "^4.0.9", "rspack-plugin-virtual-module": "^1.0.1", "sass": "^1.83.4", "sass-loader": "^16.0.4", diff --git a/src/cli/plugins/style.ts b/src/cli/plugins/style/index.ts similarity index 77% rename from src/cli/plugins/style.ts rename to src/cli/plugins/style/index.ts index c220c7f..adc074c 100644 --- a/src/cli/plugins/style.ts +++ b/src/cli/plugins/style/index.ts @@ -3,14 +3,15 @@ import path from "path"; import fs from "fs"; import {Configuration as RspackConfig, CssExtractRspackPlugin, RuleSetUse, RuleSetUseItem} from "@rspack/core"; +import {mergeStyleSources} from "./utils"; + import {definePlugin} from "@main/plugin"; import {appFilenameResolver} from "@cli/bundler"; - import {getAppSourcePath, getResolvePath, getSharedPath} from "@cli/resolvers/path"; +import {toPosix} from "@cli/utils/path"; import {ReadonlyConfig} from "@typing/config"; -import {toPosix} from "@cli/utils/path"; // prettier-ignore const styleMergerLoader = @@ -25,32 +26,28 @@ const styleMergerLoader = const appPath = getResolvePath(path.join(appDir, relativePath)); if (fs.existsSync(appPath)) { - try { - let appStyle = fs.readFileSync(appPath, "utf8"); + let appStyle = fs.readFileSync(appPath, "utf8"); - appStyle = appStyle.replace(/url\((['"]?)(.*?)\1\)/g, (match, quote, filePath) => { - if ( - filePath.startsWith("/") || - filePath.startsWith("http") || - filePath.startsWith("data:") - ) { - return match; - } + appStyle = appStyle.replace(/url\((['"]?)(.*?)\1\)/g, (match, quote, filePath) => { + if ( + filePath.startsWith("/") || + filePath.startsWith("http") || + filePath.startsWith("data:") + ) { + return match; + } - const cssDir = path.dirname(appPath); - const assetAbs = path.resolve(cssDir, filePath); + const cssDir = path.dirname(appPath); + const assetAbs = path.resolve(cssDir, filePath); - const sharedFileDir = path.dirname(sharedPath); + const sharedFileDir = path.dirname(sharedPath); - const relativeToSharedFile = path.relative(sharedFileDir, assetAbs); + const relativeToSharedFile = path.relative(sharedFileDir, assetAbs); - return `url("${toPosix(relativeToSharedFile)}")`; - }); + return `url("${toPosix(relativeToSharedFile)}")`; + }); - return sharedStyle + "\n" + appStyle; - } catch (error) { - console.error(error); - } + return mergeStyleSources(sharedStyle, appStyle); } } }; diff --git a/src/cli/plugins/style/tests/fixtures/block-atrules/app.scss b/src/cli/plugins/style/tests/fixtures/block-atrules/app.scss new file mode 100644 index 0000000..5ca1e6d --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/block-atrules/app.scss @@ -0,0 +1,5 @@ +@layer components { + .app { + color: blue; + } +} diff --git a/src/cli/plugins/style/tests/fixtures/block-atrules/shared.scss b/src/cli/plugins/style/tests/fixtures/block-atrules/shared.scss new file mode 100644 index 0000000..5e7c1cb --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/block-atrules/shared.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/charset/app.scss b/src/cli/plugins/style/tests/fixtures/charset/app.scss new file mode 100644 index 0000000..950dd9b --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/charset/app.scss @@ -0,0 +1,5 @@ +@use "sass:color"; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/charset/shared.scss b/src/cli/plugins/style/tests/fixtures/charset/shared.scss new file mode 100644 index 0000000..087f70b --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/charset/shared.scss @@ -0,0 +1,6 @@ +@charset "UTF-8"; +@use "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/comments/app.scss b/src/cli/plugins/style/tests/fixtures/comments/app.scss new file mode 100644 index 0000000..a094621 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/comments/app.scss @@ -0,0 +1,6 @@ +/* app theme */ +@use "sass:color"; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/comments/shared.scss b/src/cli/plugins/style/tests/fixtures/comments/shared.scss new file mode 100644 index 0000000..6c586ea --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/comments/shared.scss @@ -0,0 +1,6 @@ +/* shared theme */ +@use "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/conflicting-prelude/app.scss b/src/cli/plugins/style/tests/fixtures/conflicting-prelude/app.scss new file mode 100644 index 0000000..8ceba33 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/conflicting-prelude/app.scss @@ -0,0 +1,5 @@ +@use "sass:math" as map; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/conflicting-prelude/shared.scss b/src/cli/plugins/style/tests/fixtures/conflicting-prelude/shared.scss new file mode 100644 index 0000000..5e7c1cb --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/conflicting-prelude/shared.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/css-prelude/app.scss b/src/cli/plugins/style/tests/fixtures/css-prelude/app.scss new file mode 100644 index 0000000..9ea4ed8 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/css-prelude/app.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/css-prelude/shared.scss b/src/cli/plugins/style/tests/fixtures/css-prelude/shared.scss new file mode 100644 index 0000000..f6486d9 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/css-prelude/shared.scss @@ -0,0 +1,5 @@ +@import url("./reset.css"); + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-body/app.scss b/src/cli/plugins/style/tests/fixtures/duplicate-body/app.scss new file mode 100644 index 0000000..d180e39 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/duplicate-body/app.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.badge { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-body/shared.scss b/src/cli/plugins/style/tests/fixtures/duplicate-body/shared.scss new file mode 100644 index 0000000..d180e39 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/duplicate-body/shared.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.badge { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-prelude/app.scss b/src/cli/plugins/style/tests/fixtures/duplicate-prelude/app.scss new file mode 100644 index 0000000..9ea4ed8 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/duplicate-prelude/app.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-prelude/shared.scss b/src/cli/plugins/style/tests/fixtures/duplicate-prelude/shared.scss new file mode 100644 index 0000000..5e7c1cb --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/duplicate-prelude/shared.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/forward/app.scss b/src/cli/plugins/style/tests/fixtures/forward/app.scss new file mode 100644 index 0000000..950dd9b --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/forward/app.scss @@ -0,0 +1,5 @@ +@use "sass:color"; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/forward/shared.scss b/src/cli/plugins/style/tests/fixtures/forward/shared.scss new file mode 100644 index 0000000..d1fa5f7 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/forward/shared.scss @@ -0,0 +1,5 @@ +@forward "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/layer-statement/app.scss b/src/cli/plugins/style/tests/fixtures/layer-statement/app.scss new file mode 100644 index 0000000..5a13560 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/layer-statement/app.scss @@ -0,0 +1,5 @@ +@layer extra; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/layer-statement/shared.scss b/src/cli/plugins/style/tests/fixtures/layer-statement/shared.scss new file mode 100644 index 0000000..565a384 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/layer-statement/shared.scss @@ -0,0 +1,5 @@ +@layer base { + .shared { + color: red; + } +} diff --git a/src/cli/plugins/style/tests/fixtures/module-order/app.scss b/src/cli/plugins/style/tests/fixtures/module-order/app.scss new file mode 100644 index 0000000..00ae9e2 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/module-order/app.scss @@ -0,0 +1,5 @@ +@use "sass:color"; + +.badge { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/module-order/shared.scss b/src/cli/plugins/style/tests/fixtures/module-order/shared.scss new file mode 100644 index 0000000..d180e39 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/module-order/shared.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.badge { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/namespace/app.scss b/src/cli/plugins/style/tests/fixtures/namespace/app.scss new file mode 100644 index 0000000..06f2b59 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/namespace/app.scss @@ -0,0 +1,5 @@ +@namespace svg "svg-ns"; + +.app { + color: blue; +} diff --git a/src/cli/plugins/style/tests/fixtures/namespace/shared.scss b/src/cli/plugins/style/tests/fixtures/namespace/shared.scss new file mode 100644 index 0000000..5e7c1cb --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/namespace/shared.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/tests/fixtures/variables/app.scss b/src/cli/plugins/style/tests/fixtures/variables/app.scss new file mode 100644 index 0000000..5f65f79 --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/variables/app.scss @@ -0,0 +1,6 @@ +$accent: blue; +@use "sass:color"; + +.app { + color: $accent; +} diff --git a/src/cli/plugins/style/tests/fixtures/variables/shared.scss b/src/cli/plugins/style/tests/fixtures/variables/shared.scss new file mode 100644 index 0000000..5e7c1cb --- /dev/null +++ b/src/cli/plugins/style/tests/fixtures/variables/shared.scss @@ -0,0 +1,5 @@ +@use "sass:map"; + +.shared { + color: red; +} diff --git a/src/cli/plugins/style/utils.test.ts b/src/cli/plugins/style/utils.test.ts new file mode 100644 index 0000000..e91ccb9 --- /dev/null +++ b/src/cli/plugins/style/utils.test.ts @@ -0,0 +1,179 @@ +import fs from "fs"; +import path from "path"; +import sass from "sass"; + +import {mergeStyleSources} from "./utils"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures"); + +const mergeFixture = (name: string): string => { + const fixture = (...parts: string[]): string => fs.readFileSync(path.join(fixtures, name, ...parts), "utf8"); + + return mergeStyleSources(fixture("shared.scss"), fixture("app.scss")); +}; + +const normalizeStyle = (style: string): string => style.replace(/\s+/g, " ").trim(); + +describe("mergeStyleSources", () => { + test("moves app Sass module rules before merged style bodies", () => { + const merged = mergeFixture("module-order"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + '@use "sass:color";', + ".badge { color: red; }", + ".badge { color: blue; }", + ]); + }); + + test("keeps Sass variable declarations with the module prelude that uses them", () => { + const merged = mergeFixture("variables"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + "$accent: blue;", + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: $accent; }", + ]); + }); + + test("places CSS prelude after Sass module rules from both sources", () => { + const merged = mergeFixture("css-prelude"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + '@import url("./reset.css");', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("keeps leading comments in the merged prelude", () => { + const merged = mergeFixture("comments"); + + expect(merged).toMatchOrder([ + "/* shared theme */", + '@use "sass:map";', + "/* app theme */", + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("keeps block at-rules in the style body", () => { + const merged = mergeFixture("block-atrules"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + ".shared { color: red; }", + "@layer components { .app { color: blue; } }", + ]); + }); + + test("hoists @charset to the very top of the merged styles", () => { + const merged = mergeFixture("charset"); + + expect(merged).toMatchOrder([ + '@charset "UTF-8";', + '@use "sass:map";', + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("moves @forward into the Sass module prelude", () => { + const merged = mergeFixture("forward"); + + expect(merged).toMatchOrder([ + '@forward "sass:map";', + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("treats @namespace as a CSS prelude rule", () => { + const merged = mergeFixture("namespace"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + '@namespace svg "svg-ns";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("hoists @layer statements while keeping @layer blocks in the body", () => { + const merged = mergeFixture("layer-statement"); + + expect(merged).toMatchOrder([ + "@layer extra;", + "@layer base { .shared { color: red; } }", + ".app { color: blue; }", + ]); + }); + + test("collapses identical Sass module rules repeated by an app override", () => { + const merged = mergeFixture("duplicate-prelude"); + + expect(merged.match(/@use "sass:map";/g)).toHaveLength(1); + expect(() => sass.compileString(merged)).not.toThrow(); + }); + + test("keeps conflicting module rules so Sass can still report them", () => { + const merged = mergeFixture("conflicting-prelude"); + + expect(() => sass.compileString(merged)).toThrow(/already a module with namespace "map"/); + }); + + test("does not deduplicate identical style bodies", () => { + const merged = mergeFixture("duplicate-body"); + + expect(normalizeStyle(merged).match(/\.badge \{ color: red; \}/g)).toHaveLength(2); + }); +}); + +declare global { + namespace jest { + interface Matchers { + toMatchOrder(expected: string[]): R; + } + } +} + +expect.extend({ + toMatchOrder(received: string, expected: string[]) { + received = normalizeStyle(received); + expected = expected.map(normalizeStyle); + + let lastIndex = -1; + + for (const value of expected) { + const index = received.indexOf(value); + + if (index === -1) { + return { + pass: false, + message: () => `Expected merged style to contain ${this.utils.printExpected(value)}.`, + }; + } + + if (index < lastIndex) { + return { + pass: false, + message: () => `Expected ${this.utils.printExpected(value)} to appear after previous style part.`, + }; + } + + lastIndex = index; + } + + return { + pass: true, + message: () => "Expected merged style parts not to appear in order.", + }; + }, +}); diff --git a/src/cli/plugins/style/utils.ts b/src/cli/plugins/style/utils.ts new file mode 100644 index 0000000..ee22763 --- /dev/null +++ b/src/cli/plugins/style/utils.ts @@ -0,0 +1,116 @@ +import {ChildNode} from "postcss"; +import * as scss from "postcss-scss"; + +type StyleSourceParts = { + charsets: string[]; + sassPrelude: string[]; + cssPrelude: string[]; + body: string[]; +}; + +export const mergeStyleSources = (sharedStyle: string, appStyle: string): string => { + const shared = splitStyleSource(sharedStyle); + const app = splitStyleSource(appStyle); + + return [ + ...dedupePrelude([...shared.charsets, ...app.charsets]), + ...dedupePrelude([...shared.sassPrelude, ...app.sassPrelude]), + ...dedupePrelude([...shared.cssPrelude, ...app.cssPrelude]), + ...shared.body, + ...app.body, + ] + .map(part => part.trim()) + .filter(Boolean) + .join("\n\n"); +}; + +// App overrides repeat the shared @use/@forward; Sass rejects duplicate modules, so collapse exact matches while keeping differing statements that signal real conflicts. +const dedupePrelude = (parts: string[]): string[] => { + const seen = new Set(); + + return parts.filter(part => { + const key = part.trim(); + + if (seen.has(key)) { + return false; + } + + seen.add(key); + + return true; + }); +}; + +const splitStyleSource = (source: string): StyleSourceParts => { + const root = scss.parse(source); + + const parts: StyleSourceParts = { + charsets: [], + sassPrelude: [], + cssPrelude: [], + body: [], + }; + + let bodyStarted = false; + let preludeType: "sass" | "css" = "sass"; + + root.nodes?.forEach(node => { + if (!bodyStarted && isCharsetNode(node)) { + parts.charsets.push(stringifyNode(node)); + return; + } + + if (!bodyStarted && node.type === "comment") { + parts[preludeType === "sass" ? "sassPrelude" : "cssPrelude"].push(stringifyNode(node)); + return; + } + + if (!bodyStarted && isSassPreludeNode(node)) { + parts.sassPrelude.push(stringifyNode(node)); + return; + } + + if (!bodyStarted && isCssPreludeNode(node)) { + preludeType = "css"; + parts.cssPrelude.push(stringifyNode(node)); + return; + } + + bodyStarted = true; + parts.body.push(stringifyNode(node)); + }); + + return parts; +}; + +const stringifyNode = (node: ChildNode): string => { + const content = node.toString(scss.stringify); + + if ((node.type === "atrule" && !node.nodes?.length) || node.type === "decl") { + return `${content};`; + } + + return content; +}; + +const isCharsetNode = (node: ChildNode): boolean => node.type === "atrule" && node.name === "charset"; + +const isSassPreludeNode = (node: ChildNode): boolean => { + if (node.type === "decl") { + return node.prop.startsWith("$"); + } + + if (node.type !== "atrule") { + return false; + } + + return ["forward", "use"].includes(node.name); +}; + +const isCssPreludeNode = (node: ChildNode): boolean => { + if (node.type !== "atrule" || node.nodes?.length) { + return false; + } + + return ["import", "namespace", "layer"].includes(node.name); +}; From 66a7e775d3096a03f9f71edd02bd6d9e3ca466f8 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 24 May 2026 01:25:26 +0300 Subject: [PATCH 07/10] feat(csp): add CSP builder layer for extension entrypoints - Add typed CSP configs for view entrypoints and sandbox pages - Merge per-entrypoint CSP options into MV2 and MV3 manifest output - Wire page, popup, sidebar, and offscreen CSP into extension-pages CSP - Keep sandbox CSP generation browser-aware - Cover CSP builders and manifest merge behavior with tests --- src/cli/builders/csp/AbstractCsp.ts | 38 + src/cli/builders/csp/Csp.test.ts | 35 + src/cli/builders/csp/Csp.ts | 48 ++ .../csp}/SandboxCsp.test.ts | 17 +- src/cli/builders/csp/SandboxCsp.ts | 77 ++ src/cli/builders/csp/index.ts | 3 + src/cli/builders/csp/types.ts | 5 + src/cli/builders/manifest/Manifest.test.ts | 397 ++++++++++ .../builders/manifest/ManifestBase.test.ts | 700 ------------------ src/cli/builders/manifest/ManifestBase.ts | 38 +- src/cli/builders/manifest/ManifestV2.test.ts | 236 ++++++ src/cli/builders/manifest/ManifestV2.ts | 25 +- src/cli/builders/manifest/ManifestV3.test.ts | 291 ++++++++ src/cli/builders/manifest/ManifestV3.ts | 55 +- src/cli/entrypoint/file/injectors/core.ts | 10 + .../entrypoint/finder/OffscreenViewFinder.ts | 4 +- src/cli/entrypoint/finder/PageFinder.ts | 4 +- src/cli/entrypoint/finder/PopupFinder.ts | 4 +- src/cli/entrypoint/finder/SidebarFinder.ts | 4 +- .../entrypoint/finder/ViewCspFinder.test.ts | 46 ++ src/cli/entrypoint/finder/ViewCspFinder.ts | 31 + src/cli/entrypoint/finder/index.ts | 1 + .../tests/fixtures/view-csp/src/page.ts | 10 + .../tests/fixtures/view-csp/tsconfig.json | 5 + src/cli/entrypoint/parser/OffscreenParser.ts | 4 +- src/cli/entrypoint/parser/PageParser.test.ts | 39 + src/cli/entrypoint/parser/PageParser.ts | 4 +- src/cli/entrypoint/parser/PopupParser.ts | 4 +- src/cli/entrypoint/parser/SidebarParser.ts | 4 +- src/cli/entrypoint/parser/ViewCspParser.ts | 34 + .../tests/fixtures/page/options/csp/page.ts | 15 + src/cli/plugins/offscreen/index.ts | 2 + src/cli/plugins/page/index.ts | 2 +- src/cli/plugins/popup/index.ts | 2 +- src/cli/plugins/sandbox/Sandbox.ts | 12 +- src/cli/plugins/sandbox/SandboxCsp.ts | 81 -- src/cli/plugins/sandbox/index.ts | 4 +- src/cli/plugins/sidebar/index.ts | 1 + src/main/csp.ts | 2 + src/main/index.ts | 1 + src/types/csp.ts | 38 + src/types/manifest.ts | 13 +- src/types/offscreen.ts | 3 +- src/types/page.ts | 3 +- src/types/popup.ts | 3 +- src/types/sandbox.ts | 13 +- src/types/sidebar.ts | 3 +- 47 files changed, 1519 insertions(+), 852 deletions(-) create mode 100644 src/cli/builders/csp/AbstractCsp.ts create mode 100644 src/cli/builders/csp/Csp.test.ts create mode 100644 src/cli/builders/csp/Csp.ts rename src/cli/{plugins/sandbox => builders/csp}/SandboxCsp.test.ts (69%) create mode 100644 src/cli/builders/csp/SandboxCsp.ts create mode 100644 src/cli/builders/csp/index.ts create mode 100644 src/cli/builders/csp/types.ts create mode 100644 src/cli/builders/manifest/Manifest.test.ts delete mode 100644 src/cli/builders/manifest/ManifestBase.test.ts create mode 100644 src/cli/builders/manifest/ManifestV2.test.ts create mode 100644 src/cli/builders/manifest/ManifestV3.test.ts create mode 100644 src/cli/entrypoint/finder/ViewCspFinder.test.ts create mode 100644 src/cli/entrypoint/finder/ViewCspFinder.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json create mode 100644 src/cli/entrypoint/parser/PageParser.test.ts create mode 100644 src/cli/entrypoint/parser/ViewCspParser.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts delete mode 100644 src/cli/plugins/sandbox/SandboxCsp.ts create mode 100644 src/main/csp.ts create mode 100644 src/types/csp.ts diff --git a/src/cli/builders/csp/AbstractCsp.ts b/src/cli/builders/csp/AbstractCsp.ts new file mode 100644 index 0000000..d4f565b --- /dev/null +++ b/src/cli/builders/csp/AbstractCsp.ts @@ -0,0 +1,38 @@ +import type {CspBuilder} from "./types"; + +export default abstract class AbstractCsp implements CspBuilder { + protected readonly sources: Map> = new Map(); + + public abstract add(policy: TPolicy): this; + + public abstract build(): string | undefined; + + protected addSources( + sources: TSources | undefined, + directives: Partial> + ): void { + for (const [key, values] of Object.entries(sources || {}) as Array<[keyof TSources & string, string[]]>) { + const directive = directives[key]; + + if (!directive || !values) { + continue; + } + + const source = this.sources.get(directive) ?? new Set(); + + for (const value of values) { + source.add(value); + } + + this.sources.set(directive, source); + } + } + + protected sourceDirectives(): string[][] { + return Array.from(this.sources.entries()).map(([directive, values]) => [directive, ...values]); + } + + protected serialize(directives: string[][]): string { + return directives.map(parts => `${parts.join(" ")};`).join(" "); + } +} diff --git a/src/cli/builders/csp/Csp.test.ts b/src/cli/builders/csp/Csp.test.ts new file mode 100644 index 0000000..3fb6666 --- /dev/null +++ b/src/cli/builders/csp/Csp.test.ts @@ -0,0 +1,35 @@ +import Csp from "./Csp"; + +import {CspSource} from "@typing/csp"; + +describe("Csp", () => { + test("returns no policy until at least one view CSP is added", () => { + expect(new Csp().build()).toBeUndefined(); + }); + + test("merges view CSP sources into extension page directives", () => { + const policy = new Csp() + .add({ + wasm: true, + sources: { + connect: [CspSource.Self, "https://api.example.com"], + image: [CspSource.Self, CspSource.Data], + style: [CspSource.Self, CspSource.UnsafeInline], + }, + }) + .add({ + sources: { + image: [CspSource.Blob], + worker: [CspSource.Blob], + }, + }) + .build(); + + expect(policy).toContain("script-src 'self' 'wasm-unsafe-eval';"); + expect(policy).toContain("object-src 'self';"); + expect(policy).toContain("connect-src 'self' https://api.example.com;"); + expect(policy).toContain("img-src 'self' data: blob:;"); + expect(policy).toContain("style-src 'self' 'unsafe-inline';"); + expect(policy).toContain("worker-src blob:;"); + }); +}); diff --git a/src/cli/builders/csp/Csp.ts b/src/cli/builders/csp/Csp.ts new file mode 100644 index 0000000..3d6ead9 --- /dev/null +++ b/src/cli/builders/csp/Csp.ts @@ -0,0 +1,48 @@ +import {CspSource} from "@typing/csp"; +import type {CspConfig, CspSources} from "@typing/csp"; + +import AbstractCsp from "./AbstractCsp"; + +const SourceDirectives: Record = { + connect: "connect-src", + image: "img-src", + style: "style-src", + font: "font-src", + media: "media-src", + worker: "worker-src", + child: "child-src", + frame: "frame-src", +}; + +export default class Csp extends AbstractCsp { + private active = false; + private wasm = false; + + public add(csp: CspConfig): this { + this.active = true; + + if (csp.wasm) { + this.wasm = true; + } + + this.addSources(csp.sources, SourceDirectives); + + return this; + } + + public build(): string | undefined { + if (!this.active) { + return; + } + + const script = ["script-src", CspSource.Self]; + + if (this.wasm) { + script.push("'wasm-unsafe-eval'"); + } + + const directives = [script, ["object-src", CspSource.Self], ...this.sourceDirectives()]; + + return this.serialize(directives); + } +} diff --git a/src/cli/plugins/sandbox/SandboxCsp.test.ts b/src/cli/builders/csp/SandboxCsp.test.ts similarity index 69% rename from src/cli/plugins/sandbox/SandboxCsp.test.ts rename to src/cli/builders/csp/SandboxCsp.test.ts index 1ee7583..35479ee 100644 --- a/src/cli/plugins/sandbox/SandboxCsp.test.ts +++ b/src/cli/builders/csp/SandboxCsp.test.ts @@ -4,11 +4,24 @@ import {SandboxAllow, SandboxSource} from "@typing/sandbox"; describe("SandboxCsp", () => { test("builds the default sandbox policy", () => { - expect(new SandboxCsp().add().build()).toBe( + expect(new SandboxCsp().add({}).build()).toBe( "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; child-src 'self';" ); }); + test("keeps the default child source when custom sources are added", () => { + const policy = new SandboxCsp() + .add({ + sources: { + image: [SandboxSource.Self], + }, + }) + .build(); + + expect(policy).toContain("child-src 'self';"); + expect(policy).toContain("img-src 'self';"); + }); + test("merges allow tokens and sources", () => { const policy = new SandboxCsp() .add({ @@ -25,6 +38,7 @@ describe("SandboxCsp", () => { allow: [SandboxAllow.Popups], sources: { image: [SandboxSource.Blob], + child: [SandboxSource.Blob], }, }) .build(); @@ -33,5 +47,6 @@ describe("SandboxCsp", () => { expect(policy).toContain("script-src 'self' 'unsafe-eval' 'unsafe-inline';"); expect(policy).toContain("img-src 'self' data: blob:;"); expect(policy).toContain("style-src 'self' 'unsafe-inline';"); + expect(policy).toContain("child-src 'self' blob:;"); }); }); diff --git a/src/cli/builders/csp/SandboxCsp.ts b/src/cli/builders/csp/SandboxCsp.ts new file mode 100644 index 0000000..62549f8 --- /dev/null +++ b/src/cli/builders/csp/SandboxCsp.ts @@ -0,0 +1,77 @@ +import {SandboxAllow, SandboxCspConfig, SandboxSource} from "@typing/sandbox"; + +import AbstractCsp from "./AbstractCsp"; + +const SourceDirectives: Record, string> = { + connect: "connect-src", + image: "img-src", + style: "style-src", + font: "font-src", + media: "media-src", + worker: "worker-src", + child: "child-src", +}; + +export default class SandboxCsp extends AbstractCsp { + private active = false; + private eval = false; + private inline = false; + private readonly allow = new Set(); + + public add(csp: SandboxCspConfig): this { + this.active = true; + + csp = { + eval: true, + inline: false, + allow: [], + ...csp, + }; + + if (csp.eval) { + this.eval = true; + } + + if (csp.inline) { + this.inline = true; + } + + for (const value of csp.allow || []) { + this.allow.add(value); + } + + this.addSources(csp.sources, SourceDirectives); + + return this; + } + + public build(): string | undefined { + if (!this.active) { + return; + } + + const sandbox = ["sandbox", "allow-scripts", ...Array.from(this.allow).map(value => `allow-${value}`)]; + const script = ["script-src", SandboxSource.Self]; + + if (this.eval) { + script.push("'unsafe-eval'" as SandboxSource); + } + + if (this.inline) { + script.push(SandboxSource.UnsafeInline); + } + + const directives = [sandbox, script, ...this.sourceDirectives()]; + + return this.serialize(directives); + } + + protected sourceDirectives(): string[][] { + const sources = new Map(this.sources); + const child = sources.get("child-src") || new Set(); + + sources.set("child-src", new Set([SandboxSource.Self, ...child])); + + return Array.from(sources.entries()).map(([directive, values]) => [directive, ...values]); + } +} diff --git a/src/cli/builders/csp/index.ts b/src/cli/builders/csp/index.ts new file mode 100644 index 0000000..1e62ffb --- /dev/null +++ b/src/cli/builders/csp/index.ts @@ -0,0 +1,3 @@ +export {default as Csp} from "./Csp"; +export {default as SandboxCsp} from "./SandboxCsp"; +export type {CspBuilder} from "./types"; diff --git a/src/cli/builders/csp/types.ts b/src/cli/builders/csp/types.ts new file mode 100644 index 0000000..3e75f2e --- /dev/null +++ b/src/cli/builders/csp/types.ts @@ -0,0 +1,5 @@ +export interface CspBuilder { + add(csp: TCsp): this; + + build(): string | undefined; +} diff --git a/src/cli/builders/manifest/Manifest.test.ts b/src/cli/builders/manifest/Manifest.test.ts new file mode 100644 index 0000000..1b8054c --- /dev/null +++ b/src/cli/builders/manifest/Manifest.test.ts @@ -0,0 +1,397 @@ +import ManifestV3 from "./ManifestV3"; +import {Browser, DataCollectionPermission} from "@typing/browser"; +import {Language} from "@typing/locale"; +import {ManifestIncognito} from "@typing/manifest"; + +describe("Manifest primitive properties", () => { + it("name", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setName("InternalName"); + builder1.raw({name: "OptionalName"}); + expect((builder1.build() as any).name).toBe("InternalName"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({name: "OptionalName"}); + expect((builder2.build() as any).name).toBe("OptionalName"); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).name).toBe("__MSG_app_name__"); + }); + + it("short_name", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setShortName("Short"); + builder1.raw({short_name: "OptShort"}); + expect((builder1.build() as any).short_name).toBe("Short"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({short_name: "OptShort"}); + expect((builder2.build() as any).short_name).toBe("OptShort"); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).short_name).toBeUndefined(); + }); + + it("description", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setDescription("Desc"); + builder1.raw({description: "OptDesc"}); + expect((builder1.build() as any).description).toBe("Desc"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({description: "OptDesc"}); + expect((builder2.build() as any).description).toBe("OptDesc"); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).description).toBeUndefined(); + }); + + it("version", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setVersion("1.2.3"); + builder1.raw({version: "9.9.9"}); + expect((builder1.build() as any).version).toBe("1.2.3"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({version: "9.9.9"}); + expect((builder2.build() as any).version).toBe("9.9.9"); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).version).toBe("0.0.0"); + }); + + it("minimum_chrome_version", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setMinimumVersion("120.0.0"); + builder1.raw({minimum_chrome_version: "100.0.0"}); + expect((builder1.build() as any).minimum_chrome_version).toBe("120.0.0"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({minimum_chrome_version: "100.0.0"}); + expect((builder2.build() as any).minimum_chrome_version).toBe("100.0.0"); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).minimum_chrome_version).toBeUndefined(); + }); + + it("author", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setAuthor("Internal Author"); + builder1.raw({author: "Optional Author"}); + expect((builder1.build() as any).author).toBe("Internal Author"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({author: "Optional Author"}); + expect((builder2.build() as any).author).toBe("Optional Author"); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).author).toBeUndefined(); + }); + + it("homepage_url", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setHomepage("https://internal.example.com"); + builder1.raw({homepage_url: "https://raw.example.com"}); + expect((builder1.build() as any).homepage_url).toBe("https://internal.example.com"); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({homepage_url: "https://raw.example.com"}); + expect((builder2.build() as any).homepage_url).toBe("https://raw.example.com"); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).homepage_url).toBeUndefined(); + }); + + it("incognito", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setIncognito(ManifestIncognito.Split); + builder1.raw({incognito: ManifestIncognito.Spanning}); + expect((builder1.build() as any).incognito).toBe(ManifestIncognito.Split); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({incognito: ManifestIncognito.Spanning}); + expect((builder2.build() as any).incognito).toBe(ManifestIncognito.Spanning); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).incognito).toBeUndefined(); + }); + + it("default_locale", () => { + const builder1 = new ManifestV3(Browser.Chrome); + builder1.setLocale(Language.Ukrainian); + builder1.raw({default_locale: Language.English}); + expect((builder1.build() as any).default_locale).toBe(Language.Ukrainian); + + const builder2 = new ManifestV3(Browser.Chrome); + builder2.raw({default_locale: Language.English}); + expect((builder2.build() as any).default_locale).toBe(Language.English); + + const builder3 = new ManifestV3(Browser.Chrome); + expect((builder3.build() as any).default_locale).toBeUndefined(); + }); +}); + +describe("Manifest common builder methods", () => { + it("get returns the built manifest", () => { + const builder = new ManifestV3(Browser.Chrome).setName("My Addon").setVersion("1.0.0"); + + expect(builder.get()).toEqual(builder.build()); + }); + + it("merges raw objects and arrays and keeps unknown raw fields", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder + .raw({permissions: ["tabs"], chrome_url_overrides: {newtab: "first.html"}} as any) + .raw({permissions: ["storage"], commands: {cmd1: {description: "First"}}}) + .raw({commands: {cmd2: {description: "Second"}}}); + + const manifest: any = builder.build(); + + expect(manifest.permissions).toEqual(expect.arrayContaining(["tabs", "storage"])); + expect(manifest.commands).toEqual( + expect.objectContaining({ + cmd1: {description: "First"}, + cmd2: {description: "Second"}, + }) + ); + expect(manifest.chrome_url_overrides).toEqual({newtab: "first.html"}); + }); + + it("builds commands from setCommands and raw commands", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder.setCommands( + new Set([ + {name: "internal_command"}, + { + name: "common", + description: "Internal description", + chromeosKey: "Internal chromeosKey", + }, + ]) + ); + + builder.raw({ + commands: { + raw_command: {}, + common: { + description: "Raw description", + suggested_key: { + mac: "Raw macKey", + }, + }, + }, + }); + + const commands: any = builder.build().commands; + + expect(commands.raw_command).toBeDefined(); + expect(commands.internal_command).toBeDefined(); + expect(commands.common.description).toBe("Internal description"); + expect(commands.common.suggested_key.chromeos).toBe("Internal chromeosKey"); + expect(commands.common.suggested_key.mac).toBe("Raw macKey"); + }); + + it("resets commands when setCommands is called without a set", () => { + const builder = new ManifestV3(Browser.Chrome); + + const manifest: any = builder + .setCommands(new Set([{name: "internal_command"}])) + .setCommands() + .build(); + + expect(manifest.commands).toBeUndefined(); + }); + + it("selects icon groups and falls back to the default group", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder + .setIcons( + new Map([ + ["default", new Map([[16, "default16.png"]])], + ["popup", new Map([[32, "popup32.png"]])], + ]) + ) + .setIcon("popup") + .raw({icons: {48: "raw48.png"}}); + + expect((builder.build() as any).icons).toEqual({ + 32: "popup32.png", + 48: "raw48.png", + }); + + const fallback = new ManifestV3(Browser.Chrome) + .setIcons(new Map([["default", new Map([[16, "default16.png"]])]])) + .setIcon("missing") + .build() as any; + + expect(fallback.icons).toEqual({16: "default16.png"}); + }); + + it("resets icons when setIcons is called without a map", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setIcons(new Map([["default", new Map([[16, "default16.png"]])]])) + .setIcons() + .build(); + + expect(manifest.icons).toBeUndefined(); + }); + + it("collects accessible resources through add, append, set, and raw inputs", () => { + const builder = new ManifestV3(Browser.Chrome); + + builder + .addAccessibleResource({resources: ["img/add.png"], matches: ["https://add.example.com/*"]}) + .appendAccessibleResources( + new Set([{resources: ["img/append.png"], matches: ["https://append.example.com/*"]}]) + ) + .setAccessibleResource(new Set([{resources: ["img/set.png"], matches: ["https://set.example.com/*"]}])) + .raw({ + web_accessible_resources: [{resources: ["img/raw.png"], matches: ["https://raw.example.com/*"]}], + }); + + expect(builder.getWebAccessibleResources()).toEqual( + expect.arrayContaining([ + {resources: ["img/set.png"], matches: ["https://set.example.com/*"]}, + {resources: ["img/raw.png"], matches: ["https://raw.example.com/*"]}, + ]) + ); + expect(builder.getWebAccessibleResources()).not.toEqual( + expect.arrayContaining([{resources: ["img/add.png"], matches: ["https://add.example.com/*"]}]) + ); + }); +}); + +describe("Manifest browser specific settings", () => { + it("sets and merges Firefox browser specific settings", () => { + const builder = new ManifestV3(Browser.Firefox); + + builder.setSpecific({ + gecko: { + id: "initial@id", + strictMinVersion: "100.0", + dataCollectionPermissions: { + required: [DataCollectionPermission.WebsiteActivity], + optional: [DataCollectionPermission.AuthenticationInfo], + }, + }, + }); + + builder.mergeSpecific({ + gecko: { + strictMaxVersion: "120.0", + dataCollectionPermissions: { + required: [DataCollectionPermission.SearchTerms], + optional: [DataCollectionPermission.AuthenticationInfo, DataCollectionPermission.BrowsingActivity], + }, + }, + safari: { + strictMinVersion: "15", + }, + }); + + const settings: any = builder.build().browser_specific_settings; + + expect(settings.gecko.id).toBe("initial@id"); + expect(settings.gecko.strict_min_version).toBe("100.0"); + expect(settings.gecko.strict_max_version).toBe("120.0"); + expect(settings.gecko.data_collection_permissions.required).toEqual( + expect.arrayContaining([DataCollectionPermission.WebsiteActivity, DataCollectionPermission.SearchTerms]) + ); + expect(settings.gecko.data_collection_permissions.optional).toEqual( + expect.arrayContaining([ + DataCollectionPermission.AuthenticationInfo, + DataCollectionPermission.BrowsingActivity, + ]) + ); + expect(settings.gecko.data_collection_permissions.optional.length).toBe(2); + expect(settings.safari).toBeUndefined(); + }); + + it("includes Safari browser specific settings for Safari builds", () => { + const builder = new ManifestV3(Browser.Safari); + + builder + .mergeSpecific({ + safari: { + strictMinVersion: "15", + }, + }) + .raw({ + browser_specific_settings: { + safari: { + strict_max_version: "20", + }, + }, + }); + + const manifest: any = builder.build(); + + expect(manifest.browser_specific_settings.safari.strict_min_version).toBe("15"); + expect(manifest.browser_specific_settings.safari.strict_max_version).toBe("20"); + }); + + it("merges raw Firefox settings with typed Firefox settings", () => { + const builder = new ManifestV3(Browser.Firefox); + + builder + .setSpecific({ + gecko: { + dataCollectionPermissions: { + required: [DataCollectionPermission.BrowsingActivity], + }, + }, + }) + .raw({ + browser_specific_settings: { + gecko: { + id: "from@optional", + update_url: "https://example.com/update.json", + strict_min_version: "110.0", + strict_max_version: "119.0", + data_collection_permissions: { + required: [DataCollectionPermission.WebsiteActivity], + optional: [DataCollectionPermission.AuthenticationInfo], + }, + }, + gecko_android: { + strict_min_version: "110.0", + strict_max_version: "119.0", + }, + }, + }); + + const settings: any = builder.build().browser_specific_settings; + + expect(settings.gecko.id).toBe("from@optional"); + expect(settings.gecko.update_url).toBe("https://example.com/update.json"); + expect(settings.gecko.strict_min_version).toBe("110.0"); + expect(settings.gecko.strict_max_version).toBe("119.0"); + expect(settings.gecko.data_collection_permissions.required).toEqual( + expect.arrayContaining([ + DataCollectionPermission.WebsiteActivity, + DataCollectionPermission.BrowsingActivity, + ]) + ); + expect(settings.gecko_android.strict_min_version).toBe("110.0"); + expect(settings.gecko_android.strict_max_version).toBe("119.0"); + }); + + it("uses raw browser specific settings when setSpecific clears typed settings", () => { + const manifest: any = new ManifestV3(Browser.Safari) + .mergeSpecific({safari: {strictMinVersion: "15"}}) + .setSpecific() + .raw({ + browser_specific_settings: { + safari: { + strict_min_version: "16", + }, + }, + }) + .build(); + + expect(manifest.browser_specific_settings.safari.strict_min_version).toBe("16"); + }); +}); diff --git a/src/cli/builders/manifest/ManifestBase.test.ts b/src/cli/builders/manifest/ManifestBase.test.ts deleted file mode 100644 index f607fcb..0000000 --- a/src/cli/builders/manifest/ManifestBase.test.ts +++ /dev/null @@ -1,700 +0,0 @@ -import ManifestV3 from "./ManifestV3"; -import ManifestV2 from "./ManifestV2"; -import {Browser, DataCollectionPermission} from "@typing/browser"; - -const unique = (arr: string[]) => Array.from(new Set(arr)).length === arr.length; - -describe("ManifestBase primitive properties", () => { - it("name", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setName("InternalName"); - builder1.raw({name: "OptionalName"}); - const manifest1: any = builder1.build(); - expect(manifest1.name).toBe("InternalName"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({name: "OptionalName"}); - const manifest2: any = builder2.build(); - expect(manifest2.name).toBe("OptionalName"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.name).toBe("__MSG_app_name__"); - }); - - it("short_name", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setShortName("Short"); - builder1.raw({short_name: "OptShort"}); - const manifest1: any = builder1.build(); - expect(manifest1.short_name).toBe("Short"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({short_name: "OptShort"}); - const manifest2: any = builder2.build(); - expect(manifest2.short_name).toBe("OptShort"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.short_name).toBeUndefined(); - }); - - it("description", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setDescription("Desc"); - builder1.raw({description: "OptDesc"}); - const manifest1: any = builder1.build(); - expect(manifest1.description).toBe("Desc"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({description: "OptDesc"}); - const manifest2: any = builder2.build(); - expect(manifest2.description).toBe("OptDesc"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.description).toBeUndefined(); - }); - - it("version", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setVersion("1.2.3"); - builder1.raw({version: "9.9.9"}); - const manifest1: any = builder1.build(); - expect(manifest1.version).toBe("1.2.3"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({version: "9.9.9"}); - const manifest2: any = builder2.build(); - expect(manifest2.version).toBe("9.9.9"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.version).toBe("0.0.0"); - }); - - it("minimum_chrome_version", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setMinimumVersion("120.0.0"); - builder1.raw({minimum_chrome_version: "100.0.0"}); - const manifest1: any = builder1.build(); - expect(manifest1.minimum_chrome_version).toBe("120.0.0"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({minimum_chrome_version: "100.0.0"}); - const manifest2: any = builder2.build(); - expect(manifest2.minimum_chrome_version).toBe("100.0.0"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.minimum_chrome_version).toBeUndefined(); - }); - - it("author", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setAuthor("AddonBone"); - const manifest1: any = builder1.build(); - expect(manifest1.author).toBe("AddonBone"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({author: "Opt"}); - const manifest2: any = builder2.build(); - expect(manifest2.author).toBe("Opt"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.author).toBeUndefined(); - }); - - it("homepage_url", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setHomepage("https://me.example"); - const manifest1: any = builder1.build(); - expect(manifest1.homepage_url).toBe("https://me.example"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({homepage_url: "https://opt.example"}); - const manifest2: any = builder2.build(); - expect(manifest2.homepage_url).toBe("https://opt.example"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.homepage_url).toBeUndefined(); - }); - - it("incognito", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setIncognito("not_allowed" as any); - const manifest1: any = builder1.build(); - expect(manifest1.incognito).toBe("not_allowed"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({incognito: "split"}); - const manifest2: any = builder2.build(); - expect(manifest2.incognito).toBe("split"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.incognito).toBeUndefined(); - }); - - it("default_locale", () => { - const builder1 = new ManifestV3(Browser.Chrome); - builder1.setLocale("en" as any); - const manifest1: any = builder1.build(); - expect(manifest1.default_locale).toBe("en"); - - const builder2 = new ManifestV3(Browser.Chrome); - builder2.raw({default_locale: "uk"}); - const manifest2: any = builder2.build(); - expect(manifest2.default_locale).toBe("uk"); - - const builder3 = new ManifestV3(Browser.Chrome); - const manifest3: any = builder3.build(); - expect(manifest3.default_locale).toBeUndefined(); - }); -}); - -describe("ManifestBase sandbox properties", () => { - test("builds MV3 sandbox pages and content security policy", () => { - const builder = new ManifestV3(Browser.Chrome); - - builder - .raw({ - sandbox: {pages: ["sandbox/raw.html"]}, - content_security_policy: { - extension_pages: "script-src 'self'; object-src 'self';", - sandbox: "sandbox allow-scripts; script-src 'self';", - }, - } as any) - .appendSandboxes(["sandbox/parser.html", "sandbox/parser.html"]) - .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); - - const manifest: any = builder.build(); - - expect(manifest.sandbox.pages).toEqual(["sandbox/raw.html", "sandbox/parser.html"]); - expect(manifest.content_security_policy.extension_pages).toBe("script-src 'self'; object-src 'self';"); - expect(manifest.content_security_policy.sandbox).toBe( - "sandbox allow-scripts; script-src 'self' 'unsafe-eval';" - ); - }); - - test("builds MV2 sandbox content security policy inside the sandbox object", () => { - const builder = new ManifestV2(Browser.Chrome); - - builder - .raw({ - sandbox: { - pages: ["sandbox/raw.html"], - content_security_policy: "sandbox allow-scripts; script-src 'self';", - }, - } as any) - .addSandbox("sandbox/parser.html") - .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); - - const manifest: any = builder.build(); - - expect(manifest.sandbox.pages).toEqual(["sandbox/raw.html", "sandbox/parser.html"]); - expect(manifest.sandbox.content_security_policy).toBe( - "sandbox allow-scripts; script-src 'self' 'unsafe-eval';" - ); - }); - - test("does not emit sandbox manifest fields for Firefox MV3", () => { - const builder = new ManifestV3(Browser.Firefox); - - builder - .raw({ - sandbox: {pages: ["sandbox/raw.html"]}, - content_security_policy: { - extension_pages: "script-src 'self'; object-src 'self';", - sandbox: "sandbox allow-scripts; script-src 'self';", - }, - } as any) - .appendSandboxes(["sandbox/parser.html"]) - .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); - - const manifest: any = builder.build(); - - expect(manifest.sandbox).toBeUndefined(); - expect(manifest.content_security_policy).toEqual({ - extension_pages: "script-src 'self'; object-src 'self';", - }); - }); - - test("does not emit sandbox manifest fields for Firefox MV2", () => { - const builder = new ManifestV2(Browser.Firefox); - - builder - .raw({ - sandbox: { - pages: ["sandbox/raw.html"], - content_security_policy: "sandbox allow-scripts; script-src 'self';", - }, - } as any) - .addSandbox("sandbox/parser.html") - .setSandboxContentSecurityPolicy("sandbox allow-scripts; script-src 'self' 'unsafe-eval';"); - - const manifest: any = builder.build(); - - expect(manifest.sandbox).toBeUndefined(); - }); -}); - -describe("ManifestBase merged properties", () => { - it("merging objects and arrays", () => { - const builder = new ManifestV3(Browser.Chrome); - - builder - .raw({permissions: ["tabs"]}) - .raw({permissions: ["storage"]}) - .raw({commands: {cmd1: {description: "First"}}}) - .raw({commands: {cmd2: {description: "Second"}}}); - - const manifest: any = builder.build(); - - expect(manifest.permissions).toEqual(expect.arrayContaining(["tabs", "storage"])); - expect(manifest.commands).toEqual( - expect.objectContaining({ - cmd1: {description: "First"}, - cmd2: {description: "Second"}, - }) - ); - }); - - it("commands", () => { - const builder = new ManifestV3(Browser.Chrome); - - builder.setCommands( - new Set([ - {name: "internal_command"}, - { - name: "common", - description: "Internal description", - chromeosKey: "Internal chromeosKey", - }, - ]) - ); - - builder.raw({ - commands: { - raw_command: {}, - common: { - description: "Raw description", - suggested_key: { - mac: "Raw macKey", - }, - }, - }, - }); - const commands: any = builder.build().commands; - - expect(commands.raw_command).toBeDefined(); - expect(commands.internal_command).toBeDefined(); - expect(commands.common.description).toBe("Internal description"); - expect(commands.common.suggested_key.chromeos).toBe("Internal chromeosKey"); - expect(commands.common.suggested_key.mac).toBe("Raw macKey"); - }); - - it("content_scripts", () => { - const builder = new ManifestV3(Browser.Chrome); - - builder - .setDependencies( - new Map([ - [ - "entry", - { - js: new Set(["entry.js"]), - css: new Set(["entry.css"]), - assets: new Set(["entry.png"]), - }, - ], - ]) - ) - .setContentScripts( - new Set([ - { - matches: ["https://internal.com/*"], - entry: "entry", - }, - ]) - ); - - builder.raw({ - content_scripts: [ - { - matches: ["https://raw.com/*"], - js: ["raw.js"], - css: ["raw.css"], - }, - ], - }); - - const contentScripts: any = builder.build().content_scripts; - - expect(contentScripts).toBeDefined(); - expect(contentScripts.length).toBe(2); - }); - - it("icons", () => { - const builder = new ManifestV3(Browser.Chrome); - - builder.setIcons( - new Map([ - [ - "default", - new Map([ - [16, "internal16.png"], - [24, "internal24.png"], - ]), - ], - ]) - ); - - builder.raw({icons: {16: "raw16.png", 32: "raw32.png"}}); - - const icons: any = builder.build().icons; - - expect(icons["16"]).toBe("internal16.png"); - expect(icons["24"]).toBe("internal24.png"); - expect(icons["32"]).toBe("raw32.png"); - }); - - it("permissions", () => { - const builder_v3 = new ManifestV3(Browser.Chrome); - builder_v3.appendPermissions(new Set(["storage", "activeTab"])).raw({ - permissions: ["tabs"], - host_permissions: ["https://api.example.com/*"], - }); - const manifest_v3: any = builder_v3.build(); - expect(manifest_v3.host_permissions).toEqual(expect.arrayContaining(["https://api.example.com/*"])); - expect(manifest_v3.permissions).toEqual(expect.arrayContaining(["storage", "tabs"])); - - const builder_v2 = new ManifestV2(Browser.Chrome); - builder_v2 - .addPermission("storage") - .addHostPermission("https://*.example.com/*") - .raw({ - permissions: ["tabs", "activeTab"], - host_permissions: ["https://api.example.com/*"], - }); - const manifest_v2: any = builder_v2.build(); - expect(manifest_v2.host_permissions).toBeUndefined(); - expect(manifest_v2.permissions).toEqual( - expect.arrayContaining(["storage", "tabs", "https://*.example.com/*", "https://api.example.com/*"]) - ); - }); - - it("optional_permissions", () => { - const builder_v3 = new ManifestV3(Browser.Chrome); - builder_v3 - .addPermission("storage") - .addOptionalPermission("bookmarks") - .raw({optional_permissions: ["history", "storage"]}); - const manifest_v3: any = builder_v3.build(); - expect(manifest_v3.optional_permissions).toEqual(expect.arrayContaining(["bookmarks", "history"])); - expect(manifest_v3.optional_permissions).not.toEqual(expect.arrayContaining(["storage"])); - - // MV2: optional_permissions also include optional host permissions not already in host permissions - const builder_v2 = new ManifestV2(Browser.Chrome); - builder_v2 - .addPermission("storage") - .addHostPermission("https://*.example.com/*") - .setOptionalPermissions(new Set(["bookmarks"])) - .setOptionalHostPermissions(new Set(["https://opt.example.com/*", "https://*.example.com/*"])) - .raw({optional_permissions: ["history"]}); - const manifest_v2: any = builder_v2.build(); - expect(manifest_v2.optional_permissions).toEqual( - expect.arrayContaining(["bookmarks", "history", "https://opt.example.com/*"]) - ); - expect(manifest_v2.optional_permissions).not.toEqual(expect.arrayContaining(["https://*.example.com/*"])); - }); - - it("host_permissions", () => { - const builder_v3 = new ManifestV3(Browser.Chrome); - builder_v3.addHostPermission("https://*.example.com/*").raw({host_permissions: ["https://api.example.com/*"]}); - const manifest_v3: any = builder_v3.build(); - expect(manifest_v3.host_permissions).toEqual( - expect.arrayContaining(["https://*.example.com/*", "https://api.example.com/*"]) - ); - - const builder_v2 = new ManifestV2(Browser.Chrome); - builder_v2.addHostPermission("https://*.example.com/*").raw({host_permissions: ["https://api.example.com/*"]}); - const manifest_v2: any = builder_v2.build(); - expect(manifest_v2.host_permissions).toBeUndefined(); - expect(manifest_v2.permissions).toEqual( - expect.arrayContaining(["https://*.example.com/*", "https://api.example.com/*"]) - ); - }); - - it("optional_host_permissions", () => { - const builder_v3 = new ManifestV3(Browser.Chrome); - builder_v3 - .addHostPermission("https://*.example.com/*") - .setOptionalHostPermissions(new Set(["https://opt.example.com/*", "https://*.example.com/*"])) // duplicated one should be filtered out - .raw({optional_host_permissions: ["https://raw-opt.example.com/*"]}); - const manifest_v3: any = builder_v3.build(); - expect(manifest_v3.optional_host_permissions).toEqual( - expect.arrayContaining(["https://opt.example.com/*", "https://raw-opt.example.com/*"]) - ); - expect(manifest_v3.optional_host_permissions).not.toEqual(expect.arrayContaining(["https://*.example.com/*"])); - - const builder_v2 = new ManifestV2(Browser.Chrome); - builder_v2 - .addHostPermission("https://*.example.com/*") - .setOptionalHostPermissions(new Set(["https://opt.example.com/*"])); - const manifest_v2: any = builder_v2.build(); - expect(manifest_v2.optional_host_permissions).toBeUndefined(); - expect(manifest_v2.optional_permissions).toEqual(expect.arrayContaining(["https://opt.example.com/*"])); - }); - - it("web_accessible_resources (MV3)", () => { - const builder = new ManifestV3(Browser.Chrome); - - builder - .setDependencies( - new Map([ - [ - "entry", - { - js: new Set(["entry.js"]), - css: new Set(), - assets: new Set(["img/a.png", "img/b.png"]), - }, - ], - [ - "entry2", - { - js: new Set(["entry2.js"]), - css: new Set(), - assets: new Set(["img/b.png", "img/c.png"]), - }, - ], - ]) - ) - .setContentScripts( - new Set([ - { - matches: ["https://site.com/*"], - entry: "entry", - }, - { - matches: ["https://other.com/*"], - entry: "entry2", - }, - ]) - ) - .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) - .raw({ - web_accessible_resources: [ - {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, - {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, - ], - }); - - const resources: any[] = builder.build().web_accessible_resources as any[]; - expect(Array.isArray(resources)).toBe(true); - - const byMatches = (pattern: string) => resources.find(r => (r.matches || []).includes(pattern)); - - const site = byMatches("https://site.com/*"); - expect(site).toBeDefined(); - expect(site.resources).toEqual( - expect.arrayContaining([ - "img/a.png", // from deps + raw - "img/b.png", // from deps - "img/common.png", // internal - "img/raw.png", // raw only - ]) - ); - - const other = byMatches("https://other.com/*"); - expect(other).toBeDefined(); - expect(other.resources).toEqual( - expect.arrayContaining([ - "img/b.png", // from entry2 deps - "img/c.png", // from entry2 deps - "img/onlyraw.png", // raw only - ]) - ); - - // Ensure no duplicates overall within each group - expect(unique(site.resources)).toBe(true); - expect(unique(other.resources)).toBe(true); - }); - - it("web_accessible_resources (MV2)", () => { - const builder = new ManifestV2(Browser.Chrome); - - builder - .setDependencies( - new Map([ - [ - "entry", - { - js: new Set(["entry.js"]), - css: new Set(), - assets: new Set(["img/a.png", "img/b.png"]), - }, - ], - [ - "entry2", - { - js: new Set(["entry2.js"]), - css: new Set(), - assets: new Set(["img/b.png", "img/c.png"]), - }, - ], - ]) - ) - .setContentScripts( - new Set([ - {matches: ["https://site.com/*"], entry: "entry"}, - {matches: ["https://other.com/*"], entry: "entry2"}, - ]) - ) - .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) - .raw({ - web_accessible_resources: [ - {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, - {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, - ], - }); - - const resources: any[] = (builder.build() as any).web_accessible_resources; - expect(Array.isArray(resources)).toBe(true); - - // MV2 flattens to unique list of strings - expect(resources).toEqual( - expect.arrayContaining([ - "img/a.png", - "img/b.png", - "img/c.png", - "img/common.png", - "img/raw.png", - "img/onlyraw.png", - ]) - ); - - // Ensure uniqueness - - expect(unique(resources)).toBe(true); - }); -}); - -describe("ManifestBase mergeSpecific", () => { - it("should perform a deep merge of browser specific settings", () => { - const builder = new ManifestV3(Browser.Firefox); - - builder.setSpecific({ - gecko: { - id: "initial@id", - strictMinVersion: "100.0", - dataCollectionPermissions: { - required: [DataCollectionPermission.WebsiteActivity], - optional: [DataCollectionPermission.AuthenticationInfo], - }, - }, - }); - - builder.mergeSpecific({ - gecko: { - strictMaxVersion: "120.0", - dataCollectionPermissions: { - required: [DataCollectionPermission.SearchTerms], - optional: [DataCollectionPermission.AuthenticationInfo, DataCollectionPermission.BrowsingActivity], - }, - }, - safari: { - strictMinVersion: "15", - }, - }); - - const manifest: any = builder.build(); - const settings = manifest.browser_specific_settings; - - expect(settings.gecko.id).toBe("initial@id"); - expect(settings.gecko.strict_min_version).toBe("100.0"); - expect(settings.gecko.strict_max_version).toBe("120.0"); - - // Check union of arrays - expect(settings.gecko.data_collection_permissions.required).toContain(DataCollectionPermission.WebsiteActivity); - expect(settings.gecko.data_collection_permissions.required).toContain(DataCollectionPermission.SearchTerms); - expect(settings.gecko.data_collection_permissions.required.length).toBe(2); - - expect(settings.gecko.data_collection_permissions.optional).toContain( - DataCollectionPermission.AuthenticationInfo - ); - expect(settings.gecko.data_collection_permissions.optional).toContain( - DataCollectionPermission.BrowsingActivity - ); - expect(settings.gecko.data_collection_permissions.optional.length).toBe(2); // AuthenticationInfo should not be duplicated - - expect(settings.safari).toBeUndefined(); // buildBrowserSpecificSettings for Firefox doesn't include safari - }); - - it("should include safari settings when browser is Safari", () => { - const builder = new ManifestV3(Browser.Safari); - builder.mergeSpecific({ - safari: { - strictMinVersion: "15", - }, - }); - - builder.raw({ - browser_specific_settings: { - safari: { - strict_max_version: "20", - }, - }, - }); - - const manifest: any = builder.build(); - expect(manifest.browser_specific_settings.safari.strict_min_version).toBe("15"); - expect(manifest.browser_specific_settings.safari.strict_max_version).toBe("20"); - }); - - it("should use raw for gecko settings when specific is not set", () => { - const builder = new ManifestV3(Browser.Firefox); - builder - .setSpecific({ - gecko: { - dataCollectionPermissions: { - required: [DataCollectionPermission.BrowsingActivity], - }, - }, - }) - .raw({ - browser_specific_settings: { - gecko: { - id: "from@optional", - update_url: "https://example.com/update.json", - strict_min_version: "110.0", - strict_max_version: "119.0", - data_collection_permissions: { - required: [DataCollectionPermission.WebsiteActivity], - optional: [DataCollectionPermission.AuthenticationInfo], - }, - }, - gecko_android: { - strict_min_version: "110.0", - strict_max_version: "119.0", - }, - }, - }); - - const settings: any = builder.build().browser_specific_settings; - - expect(settings.gecko.id).toBe("from@optional"); - expect(settings.gecko.update_url).toBe("https://example.com/update.json"); - expect(settings.gecko.strict_min_version).toBe("110.0"); - expect(settings.gecko.strict_max_version).toBe("119.0"); - expect(settings.gecko.data_collection_permissions.required).toContain(DataCollectionPermission.WebsiteActivity); - expect(settings.gecko.data_collection_permissions.required).toContain( - DataCollectionPermission.BrowsingActivity - ); - expect(settings.gecko_android.strict_min_version).toBe("110.0"); - expect(settings.gecko_android.strict_max_version).toBe("119.0"); - }); -}); diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index d4f2e2c..c38b5f3 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -1,5 +1,7 @@ import _ from "lodash"; +import {Csp, type CspBuilder, SandboxCsp} from "../csp"; + import {mergeWebAccessibleResources, normalizeDataCollectionPermissions} from "./utils"; import { @@ -19,12 +21,13 @@ import { ManifestPermissions, ManifestPopup, ManifestSandbox, - ManifestSandboxContentSecurityPolicy, ManifestSandboxes, ManifestSidebar, ManifestVersion, OptionalManifest, } from "@typing/manifest"; +import {CspConfig} from "@typing/csp"; +import {SandboxCspConfig} from "@typing/sandbox"; import {Browser, BrowserSpecific} from "@typing/browser"; import {Language} from "@typing/locale"; import {CommandExecuteActionName} from "@typing/command"; @@ -60,7 +63,8 @@ export default abstract class implements ManifestBuilder protected popup?: ManifestPopup; protected sidebar?: ManifestSidebar; protected sandboxes: ManifestSandboxes = new Set(); - protected sandboxContentSecurityPolicy?: ManifestSandboxContentSecurityPolicy; + protected sandboxCsp: CspBuilder = new SandboxCsp(); + protected csp: CspBuilder = new Csp(); protected commands: ManifestCommands = new Set(); protected contentScripts: ManifestContentScripts = new Set(); protected dependencies: ManifestDependencies = new Map(); @@ -89,7 +93,7 @@ export default abstract class implements ManifestBuilder protected abstract buildSandbox(): Partial | undefined; - protected abstract buildContentSecurityPolicy(): Partial | undefined; + protected abstract buildCsp(): Partial | undefined; protected get combinedRaws(): OptionalManifest { return (this.mergedRaws ??= Array.from(this.raws).reduce((result, raw) => { @@ -275,8 +279,30 @@ export default abstract class implements ManifestBuilder return this; } - public setSandboxContentSecurityPolicy(policy?: ManifestSandboxContentSecurityPolicy): this { - this.sandboxContentSecurityPolicy = policy; + public addSandboxCsp(csp: SandboxCspConfig): this { + this.sandboxCsp.add(csp); + + return this; + } + + public appendSandboxCsp(csps: Iterable): this { + for (const csp of csps) { + this.addSandboxCsp(csp); + } + + return this; + } + + public addCsp(csp: CspConfig): this { + this.csp.add(csp); + + return this; + } + + public appendCsp(csps: Iterable): this { + for (const csp of csps) { + this.addCsp(csp); + } return this; } @@ -417,7 +443,7 @@ export default abstract class implements ManifestBuilder this.buildOptionalHostPermissions(), this.buildWebAccessibleResources(), this.buildSandbox(), - this.buildContentSecurityPolicy(), + this.buildCsp(), this.buildBrowserSpecificSettings(), this.buildRaw() ) as T; diff --git a/src/cli/builders/manifest/ManifestV2.test.ts b/src/cli/builders/manifest/ManifestV2.test.ts new file mode 100644 index 0000000..302b2f8 --- /dev/null +++ b/src/cli/builders/manifest/ManifestV2.test.ts @@ -0,0 +1,236 @@ +import ManifestV2 from "./ManifestV2"; +import {Browser} from "@typing/browser"; +import {CommandExecuteActionName} from "@typing/command"; + +const unique = (arr: string[]) => Array.from(new Set(arr)).length === arr.length; + +const dependency = (js: string[] = [], css: string[] = [], assets: string[] = []) => ({ + js: new Set(js), + css: new Set(css), + assets: new Set(assets), +}); + +describe("ManifestV2", () => { + it("returns manifest version 2", () => { + expect(new ManifestV2(Browser.Chrome).getManifestVersion()).toBe(2); + expect((new ManifestV2(Browser.Chrome).build() as any).manifest_version).toBe(2); + }); + + it("builds background scripts from dependencies", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .setDependencies(new Map([["background", dependency(["background.js", "vendor.js"])]])) + .setBackground({entry: "background", persistent: true}) + .build(); + + expect(manifest.background).toEqual({ + scripts: ["background.js", "vendor.js"], + persistent: true, + }); + }); + + it("builds browser_action from popup and selected icons", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .setName("Popup Addon") + .setIcons(new Map([["popup", new Map([[16, "popup16.png"]])]])) + .setPopup({path: "popup.html", title: "Popup", icon: "popup"}) + .build(); + + expect(manifest.browser_action).toEqual({ + default_title: "Popup", + default_popup: "popup.html", + default_icon: {16: "popup16.png"}, + }); + }); + + it("builds browser_action for execute action commands", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .setName("Command Addon") + .setCommands(new Set([{name: CommandExecuteActionName}])) + .build(); + + expect(manifest.browser_action).toEqual({default_title: "Command Addon"}); + }); + + it("builds sidebar_action only for MV2 browsers that support it", () => { + const opera: any = new ManifestV2(Browser.Opera).setSidebar({path: "sidebar.html", title: "Sidebar"}).build(); + const chrome: any = new ManifestV2(Browser.Chrome).setSidebar({path: "sidebar.html", title: "Sidebar"}).build(); + + expect(opera.sidebar_action.default_panel).toBe("sidebar.html"); + expect(opera.sidebar_action.default_title).toBe("Sidebar"); + expect(chrome.sidebar_action).toBeUndefined(); + }); + + it("strips MV3-only content script options", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .setDependencies(new Map([["content", dependency(["content.js"], ["content.css"])]])) + .setContentScripts( + new Set([ + { + entry: "content", + matches: ["https://example.com/*"], + world: "MAIN" as any, + matchOriginAsFallback: true, + }, + ]) + ) + .build(); + + expect(manifest.content_scripts[0]).toEqual({ + matches: ["https://example.com/*"], + exclude_matches: undefined, + js: ["content.js"], + css: ["content.css"], + all_frames: undefined, + run_at: undefined, + exclude_globs: undefined, + include_globs: undefined, + match_about_blank: undefined, + }); + }); + + it("merges permissions and host permissions into permissions", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .setPermissions(new Set(["storage"])) + .appendPermissions(new Set(["tabs"])) + .addPermission("activeTab") + .setHostPermissions(new Set(["https://set.example.com/*"])) + .appendHostPermissions(new Set(["https://append.example.com/*"])) + .addHostPermission("https://add.example.com/*") + .raw({ + permissions: ["bookmarks"], + host_permissions: ["https://raw.example.com/*"], + }) + .build(); + + expect(manifest.host_permissions).toBeUndefined(); + expect(manifest.permissions).toEqual( + expect.arrayContaining([ + "storage", + "tabs", + "bookmarks", + "https://set.example.com/*", + "https://append.example.com/*", + "https://add.example.com/*", + "https://raw.example.com/*", + ]) + ); + }); + + it("merges optional permissions and optional host permissions into optional_permissions", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .setPermissions(new Set(["storage"])) + .setHostPermissions(new Set(["https://required.example.com/*"])) + .setOptionalPermissions(new Set(["bookmarks"])) + .appendOptionalPermissions(new Set(["history", "storage"])) + .addOptionalPermission("downloads") + .setOptionalHostPermissions(new Set(["https://optional.example.com/*"])) + .appendOptionalHostPermissions(new Set(["https://required.example.com/*"])) + .addOptionalHostPermission("https://add-optional.example.com/*") + .raw({optional_permissions: ["sessions"]}) + .build(); + + expect(manifest.optional_host_permissions).toBeUndefined(); + expect(manifest.optional_permissions).toEqual( + expect.arrayContaining([ + "bookmarks", + "history", + "downloads", + "sessions", + "https://optional.example.com/*", + "https://add-optional.example.com/*", + ]) + ); + expect(manifest.optional_permissions).not.toEqual(expect.arrayContaining(["storage"])); + expect(manifest.optional_permissions).not.toEqual(expect.arrayContaining(["https://required.example.com/*"])); + }); + + it("flattens web accessible resources", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .setDependencies( + new Map([ + ["entry", dependency(["entry.js"], [], ["img/a.png", "img/b.png"])], + ["entry2", dependency(["entry2.js"], [], ["img/b.png", "img/c.png"])], + ]) + ) + .setContentScripts( + new Set([ + {matches: ["https://site.com/*"], entry: "entry"}, + {matches: ["https://other.com/*"], entry: "entry2"}, + ]) + ) + .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) + .raw({ + web_accessible_resources: [ + {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, + {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, + ], + }) + .build(); + + expect(manifest.web_accessible_resources).toEqual( + expect.arrayContaining([ + "img/a.png", + "img/b.png", + "img/c.png", + "img/common.png", + "img/raw.png", + "img/onlyraw.png", + ]) + ); + expect(unique(manifest.web_accessible_resources)).toBe(true); + }); + + it("builds sandbox pages and sandbox content security policy inside the sandbox object", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .raw({ + sandbox: { + pages: ["sandbox/raw.html"], + content_security_policy: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .addSandbox("sandbox/parser.html") + .appendSandboxes(["sandbox/extra.html", "sandbox/parser.html"]) + .appendSandboxCsp([{eval: true, sources: {}}]) + .build(); + + expect(manifest.sandbox.pages).toEqual(["sandbox/raw.html", "sandbox/parser.html", "sandbox/extra.html"]); + expect(manifest.sandbox.content_security_policy).toBe( + "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; child-src 'self';" + ); + }); + + it("does not emit sandbox manifest fields for Firefox", () => { + const manifest: any = new ManifestV2(Browser.Firefox) + .raw({ + sandbox: { + pages: ["sandbox/raw.html"], + content_security_policy: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .addSandbox("sandbox/parser.html") + .addSandboxCsp({eval: true, sources: {}}) + .build(); + + expect(manifest.sandbox).toBeUndefined(); + }); + + it("builds MV2 content security policy as a manifest string", () => { + const manifest: any = new ManifestV2(Browser.Chrome) + .appendCsp([{sources: {connect: ["https://api.example.com"]}}]) + .build(); + + expect(manifest.content_security_policy).toBe( + "script-src 'self'; object-src 'self'; connect-src https://api.example.com;" + ); + }); + + it("rejects raw content_security_policy when generated CSP is added", () => { + const builder = new ManifestV2(Browser.Chrome) + .raw({content_security_policy: "script-src 'self';"} as any) + .addCsp({sources: {connect: ["https://api.example.com"]}}); + + expect(() => builder.build()).toThrow( + "Cannot merge extension pages content security policy with a raw content_security_policy." + ); + }); +}); diff --git a/src/cli/builders/manifest/ManifestV2.ts b/src/cli/builders/manifest/ManifestV2.ts index 7eb422e..9bab70c 100644 --- a/src/cli/builders/manifest/ManifestV2.ts +++ b/src/cli/builders/manifest/ManifestV2.ts @@ -1,4 +1,4 @@ -import ManifestBase from "./ManifestBase"; +import ManifestBase, {ManifestError} from "./ManifestBase"; import {filterHostPatterns, filterOptionalPermissions, filterPermissionsForMV2} from "./utils"; @@ -123,9 +123,9 @@ export default class extends ManifestBase { const rawSandbox = (this.combinedRaws.sandbox || {}) as Record; const sandboxes = this.getSandboxes(); - const contentSecurityPolicy = this.sandboxContentSecurityPolicy || rawSandbox.content_security_policy; + const csp = this.sandboxCsp.build() || rawSandbox.content_security_policy; - if (sandboxes.length === 0 && !contentSecurityPolicy && Object.keys(rawSandbox).length === 0) { + if (sandboxes.length === 0 && !csp && Object.keys(rawSandbox).length === 0) { return; } @@ -133,16 +133,23 @@ export default class extends ManifestBase { sandbox: { ...rawSandbox, pages: sandboxes, - ...(contentSecurityPolicy ? {content_security_policy: contentSecurityPolicy} : {}), + ...(csp ? {content_security_policy: csp} : {}), }, } as Partial; } - protected buildContentSecurityPolicy(): Partial | undefined { - const contentSecurityPolicy = this.combinedRaws.content_security_policy; + protected buildCsp(): Partial | undefined { + const rawCsp = this.combinedRaws.content_security_policy; + const csp = this.csp.build(); - return contentSecurityPolicy - ? ({content_security_policy: contentSecurityPolicy} as Partial) - : undefined; + if (rawCsp && csp) { + throw new ManifestError( + "Cannot merge extension pages content security policy with a raw content_security_policy." + ); + } + + const policy = rawCsp || csp; + + return policy ? ({content_security_policy: policy} as Partial) : undefined; } } diff --git a/src/cli/builders/manifest/ManifestV3.test.ts b/src/cli/builders/manifest/ManifestV3.test.ts new file mode 100644 index 0000000..07f8a0b --- /dev/null +++ b/src/cli/builders/manifest/ManifestV3.test.ts @@ -0,0 +1,291 @@ +import ManifestV3 from "./ManifestV3"; +import {Browser} from "@typing/browser"; +import {CommandExecuteActionName} from "@typing/command"; + +const unique = (arr: string[]) => Array.from(new Set(arr)).length === arr.length; + +const dependency = (js: string[] = [], css: string[] = [], assets: string[] = []) => ({ + js: new Set(js), + css: new Set(css), + assets: new Set(assets), +}); + +describe("ManifestV3", () => { + it("returns manifest version 3", () => { + expect(new ManifestV3(Browser.Chrome).getManifestVersion()).toBe(3); + expect((new ManifestV3(Browser.Chrome).build() as any).manifest_version).toBe(3); + }); + + it("builds service worker background from dependencies", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setDependencies(new Map([["background", dependency(["background.js"])]])) + .setBackground({entry: "background"}) + .build(); + + expect(manifest.background).toEqual({service_worker: "background.js"}); + }); + + it("builds Firefox background scripts without persistent", () => { + const manifest: any = new ManifestV3(Browser.Firefox) + .setDependencies(new Map([["background", dependency(["background.js"])]])) + .setBackground({entry: "background", persistent: true}) + .build(); + + expect(manifest.background).toEqual({scripts: ["background.js"], persistent: undefined}); + }); + + it("builds action from popup and selected icons", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setName("Popup Addon") + .setIcons(new Map([["popup", new Map([[16, "popup16.png"]])]])) + .setPopup({path: "popup.html", title: "Popup", icon: "popup"}) + .build(); + + expect(manifest.action).toEqual({ + default_title: "Popup", + default_popup: "popup.html", + default_icon: {16: "popup16.png"}, + }); + }); + + it("builds action for execute action commands", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setName("Command Addon") + .setCommands(new Set([{name: CommandExecuteActionName}])) + .build(); + + expect(manifest.action).toEqual({default_title: "Command Addon"}); + }); + + it("builds side_panel for Chrome and sidebar_action for alternative browsers", () => { + const chrome: any = new ManifestV3(Browser.Chrome).setSidebar({path: "sidebar.html", title: "Sidebar"}).build(); + const firefox: any = new ManifestV3(Browser.Firefox) + .setSidebar({path: "sidebar.html", title: "Sidebar"}) + .build(); + + expect(chrome.side_panel.default_path).toBe("sidebar.html"); + expect(chrome.side_panel.default_title).toBe("Sidebar"); + expect(firefox.sidebar_action.default_panel).toBe("sidebar.html"); + expect(firefox.sidebar_action.open_at_install).toBe(false); + }); + + it("builds MV3 content scripts with MV3-only options", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setDependencies(new Map([["content", dependency(["content.js"], ["content.css"])]])) + .setContentScripts( + new Set([ + { + entry: "content", + matches: ["https://example.com/*"], + world: "MAIN" as any, + matchOriginAsFallback: true, + matchAboutBlank: true, + }, + ]) + ) + .build(); + + expect(manifest.content_scripts[0]).toEqual({ + matches: ["https://example.com/*"], + exclude_matches: undefined, + js: ["content.js"], + css: ["content.css"], + all_frames: undefined, + run_at: undefined, + exclude_globs: undefined, + include_globs: undefined, + match_about_blank: true, + match_origin_as_fallback: true, + world: "MAIN", + }); + }); + + it("builds permissions separately from host permissions", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setPermissions(new Set(["storage"])) + .appendPermissions(new Set(["tabs"])) + .addPermission("activeTab") + .setHostPermissions(new Set(["https://set.example.com/*"])) + .appendHostPermissions(new Set(["https://append.example.com/*"])) + .addHostPermission("https://add.example.com/*") + .raw({ + permissions: ["bookmarks"], + host_permissions: ["https://raw.example.com/*"], + }) + .build(); + + expect(manifest.permissions).toEqual(expect.arrayContaining(["storage", "tabs", "bookmarks"])); + expect(manifest.host_permissions).toEqual( + expect.arrayContaining([ + "https://set.example.com/*", + "https://append.example.com/*", + "https://add.example.com/*", + "https://raw.example.com/*", + ]) + ); + }); + + it("builds optional permissions separately from optional host permissions", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setPermissions(new Set(["storage"])) + .setHostPermissions(new Set(["https://required.example.com/*"])) + .setOptionalPermissions(new Set(["bookmarks"])) + .appendOptionalPermissions(new Set(["history", "storage"])) + .addOptionalPermission("downloads") + .setOptionalHostPermissions(new Set(["https://optional.example.com/*"])) + .appendOptionalHostPermissions(new Set(["https://required.example.com/*"])) + .addOptionalHostPermission("https://add-optional.example.com/*") + .raw({ + optional_permissions: ["sessions"], + optional_host_permissions: ["https://raw-optional.example.com/*"], + }) + .build(); + + expect(manifest.optional_permissions).toEqual( + expect.arrayContaining(["bookmarks", "history", "downloads", "sessions"]) + ); + expect(manifest.optional_permissions).not.toEqual(expect.arrayContaining(["storage"])); + expect(manifest.optional_host_permissions).toEqual( + expect.arrayContaining([ + "https://optional.example.com/*", + "https://add-optional.example.com/*", + "https://raw-optional.example.com/*", + ]) + ); + expect(manifest.optional_host_permissions).not.toEqual( + expect.arrayContaining(["https://required.example.com/*"]) + ); + }); + + it("groups web accessible resources by match patterns", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .setDependencies( + new Map([ + ["entry", dependency(["entry.js"], [], ["img/a.png", "img/b.png"])], + ["entry2", dependency(["entry2.js"], [], ["img/b.png", "img/c.png"])], + ]) + ) + .setContentScripts( + new Set([ + {matches: ["https://site.com/*"], entry: "entry"}, + {matches: ["https://other.com/*"], entry: "entry2"}, + ]) + ) + .addAccessibleResource({resources: ["img/common.png"], matches: ["https://site.com/*"]}) + .raw({ + web_accessible_resources: [ + {resources: ["img/raw.png", "img/a.png"], matches: ["https://site.com/*"]}, + {resources: ["img/onlyraw.png"], matches: ["https://other.com/*"]}, + ], + }) + .build(); + + const resources: any[] = manifest.web_accessible_resources; + const byMatches = (pattern: string) => resources.find(r => (r.matches || []).includes(pattern)); + const site = byMatches("https://site.com/*"); + const other = byMatches("https://other.com/*"); + + expect(site.resources).toEqual( + expect.arrayContaining(["img/a.png", "img/b.png", "img/common.png", "img/raw.png"]) + ); + expect(other.resources).toEqual(expect.arrayContaining(["img/b.png", "img/c.png", "img/onlyraw.png"])); + expect(unique(site.resources)).toBe(true); + expect(unique(other.resources)).toBe(true); + }); + + it("builds sandbox pages and sandbox content security policy", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .raw({ + sandbox: {pages: ["sandbox/raw.html"]}, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self';", + sandbox: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .appendSandboxes(["sandbox/parser.html", "sandbox/parser.html"]) + .appendSandboxCsp([{eval: true, sources: {}}]) + .build(); + + expect(manifest.sandbox.pages).toEqual(["sandbox/raw.html", "sandbox/parser.html"]); + expect(manifest.content_security_policy.extension_pages).toBe("script-src 'self'; object-src 'self';"); + expect(manifest.content_security_policy.sandbox).toBe( + "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; child-src 'self';" + ); + }); + + it("does not emit sandbox manifest fields for Firefox", () => { + const manifest: any = new ManifestV3(Browser.Firefox) + .raw({ + sandbox: {pages: ["sandbox/raw.html"]}, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self';", + sandbox: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .appendSandboxes(["sandbox/parser.html"]) + .addSandboxCsp({eval: true, sources: {}}) + .build(); + + expect(manifest.sandbox).toBeUndefined(); + expect(manifest.content_security_policy).toEqual({ + extension_pages: "script-src 'self'; object-src 'self';", + }); + }); + + it("builds extension page content security policy", () => { + const manifest: any = new ManifestV3(Browser.Chrome) + .addCsp({ + wasm: true, + sources: { + connect: ["'self'", "https://api.example.com"], + image: ["'self'", "data:"], + }, + }) + .appendCsp([ + { + sources: { + image: ["blob:"], + worker: ["blob:"], + }, + }, + ]) + .build(); + + expect(manifest.content_security_policy.extension_pages).toBe( + "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' https://api.example.com; img-src 'self' data: blob:; worker-src blob:;" + ); + }); + + it("keeps Firefox extension page policy and strips sandbox policy", () => { + const manifest: any = new ManifestV3(Browser.Firefox) + .raw({ + content_security_policy: { + sandbox: "sandbox allow-scripts; script-src 'self';", + }, + } as any) + .addCsp({ + sources: { + connect: ["https://api.example.com"], + }, + }) + .build(); + + expect(manifest.content_security_policy).toEqual({ + extension_pages: "script-src 'self'; object-src 'self'; connect-src https://api.example.com;", + }); + }); + + it("rejects raw extension_pages when generated CSP is added", () => { + const builder = new ManifestV3(Browser.Chrome) + .raw({ + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'self';", + }, + } as any) + .addCsp({sources: {connect: ["https://api.example.com"]}}); + + expect(() => builder.build()).toThrow( + "Cannot merge extension pages content security policy with raw content_security_policy.extension_pages." + ); + }); +}); diff --git a/src/cli/builders/manifest/ManifestV3.ts b/src/cli/builders/manifest/ManifestV3.ts index d2a6498..2dc6ab7 100644 --- a/src/cli/builders/manifest/ManifestV3.ts +++ b/src/cli/builders/manifest/ManifestV3.ts @@ -141,43 +141,68 @@ export default class extends ManifestBase { } as Partial; } - protected buildContentSecurityPolicy(): Partial | undefined { - const rawContentSecurityPolicy = this.combinedRaws.content_security_policy; + protected buildCsp(): Partial | undefined { + const rawCsp = this.combinedRaws.content_security_policy; + const csp = this.csp.build(); + const sandboxCsp = this.sandboxCsp.build(); if (this.browser === Browser.Firefox) { - if (!rawContentSecurityPolicy) { + if (!rawCsp && !csp) { return; } - if (typeof rawContentSecurityPolicy === "string") { - return {content_security_policy: rawContentSecurityPolicy} as Partial; + if (typeof rawCsp === "string") { + if (csp) { + throw new ManifestError( + "Cannot merge extension pages content security policy with a string content_security_policy." + ); + } + + return {content_security_policy: rawCsp} as Partial; } - const {sandbox, ...contentSecurityPolicy} = rawContentSecurityPolicy; + const {sandbox, ...contentCsp} = rawCsp || {}; + + if (csp && contentCsp.extension_pages) { + throw new ManifestError( + "Cannot merge extension pages content security policy with raw content_security_policy.extension_pages." + ); + } - return Object.keys(contentSecurityPolicy).length > 0 - ? ({content_security_policy: contentSecurityPolicy} as Partial) + if (csp) { + contentCsp.extension_pages = csp; + } + + return Object.keys(contentCsp).length > 0 + ? ({content_security_policy: contentCsp} as Partial) : undefined; } - if (!rawContentSecurityPolicy && !this.sandboxContentSecurityPolicy) { + if (!rawCsp && !sandboxCsp && !csp) { return; } - if (typeof rawContentSecurityPolicy === "string") { - if (this.sandboxContentSecurityPolicy) { + if (typeof rawCsp === "string") { + if (sandboxCsp || csp) { throw new ManifestError( - "Cannot merge sandbox content security policy with a string content_security_policy." + "Cannot merge framework content security policy with a string content_security_policy." ); } - return {content_security_policy: rawContentSecurityPolicy} as Partial; + return {content_security_policy: rawCsp} as Partial; + } + + if (csp && rawCsp?.extension_pages) { + throw new ManifestError( + "Cannot merge extension pages content security policy with raw content_security_policy.extension_pages." + ); } return { content_security_policy: { - ...rawContentSecurityPolicy, - sandbox: this.sandboxContentSecurityPolicy || rawContentSecurityPolicy?.sandbox, + ...rawCsp, + ...(csp ? {extension_pages: csp} : {}), + ...(sandboxCsp || rawCsp?.sandbox ? {sandbox: sandboxCsp || rawCsp?.sandbox} : {}), }, } as Partial; } diff --git a/src/cli/entrypoint/file/injectors/core.ts b/src/cli/entrypoint/file/injectors/core.ts index 12c30b8..8fe447a 100644 --- a/src/cli/entrypoint/file/injectors/core.ts +++ b/src/cli/entrypoint/file/injectors/core.ts @@ -4,6 +4,7 @@ import {RelayMethod} from "@typing/relay"; import {ContentScriptAppend, ContentScriptDeclarative, ContentScriptMarker} from "@typing/content"; import {OffscreenReason} from "@typing/offscreen"; import {SandboxAllow, SandboxSource} from "@typing/sandbox"; +import {CspSource} from "@typing/csp"; import {Injector} from "../types"; @@ -64,6 +65,15 @@ export default (): Injector[] => { }); }); + Object.entries(CspSource).forEach(([key, value]) => { + resolvers.push({ + from: PackageName, + target: "CspSource", + name: key, + value, + }); + }); + Object.entries(RelayMethod).forEach(([key, value]) => { resolvers.push({ from: PackageName, diff --git a/src/cli/entrypoint/finder/OffscreenViewFinder.ts b/src/cli/entrypoint/finder/OffscreenViewFinder.ts index 27bebe8..3a12aa1 100644 --- a/src/cli/entrypoint/finder/OffscreenViewFinder.ts +++ b/src/cli/entrypoint/finder/OffscreenViewFinder.ts @@ -1,11 +1,11 @@ -import AbstractViewFinder from "./AbstractViewFinder"; +import ViewCspFinder from "./ViewCspFinder"; import AbstractTransportFinder from "./AbstractTransportFinder"; import {ReadonlyConfig} from "@typing/config"; import {OffscreenEntrypointOptions} from "@typing/offscreen"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends AbstractViewFinder { +export default class extends ViewCspFinder { constructor( config: ReadonlyConfig, protected readonly finder: AbstractTransportFinder diff --git a/src/cli/entrypoint/finder/PageFinder.ts b/src/cli/entrypoint/finder/PageFinder.ts index 1126acd..5a7dfca 100644 --- a/src/cli/entrypoint/finder/PageFinder.ts +++ b/src/cli/entrypoint/finder/PageFinder.ts @@ -1,4 +1,4 @@ -import AbstractViewFinder from "./AbstractViewFinder"; +import ViewCspFinder from "./ViewCspFinder"; import PluginFinder from "./PluginFinder"; import {PageParser} from "../parser"; @@ -14,7 +14,7 @@ import { EntrypointType, } from "@typing/entrypoint"; -export default class extends AbstractViewFinder { +export default class extends ViewCspFinder { constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/finder/PopupFinder.ts b/src/cli/entrypoint/finder/PopupFinder.ts index 8bfa724..befb88e 100644 --- a/src/cli/entrypoint/finder/PopupFinder.ts +++ b/src/cli/entrypoint/finder/PopupFinder.ts @@ -1,4 +1,4 @@ -import AbstractViewFinder from "./AbstractViewFinder"; +import ViewCspFinder from "./ViewCspFinder"; import PluginFinder from "./PluginFinder"; import {PopupParser} from "../parser"; @@ -7,7 +7,7 @@ import {ReadonlyConfig} from "@typing/config"; import {PopupEntrypointOptions} from "@typing/popup"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends AbstractViewFinder { +export default class extends ViewCspFinder { constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/finder/SidebarFinder.ts b/src/cli/entrypoint/finder/SidebarFinder.ts index 33da772..a9f4f1b 100644 --- a/src/cli/entrypoint/finder/SidebarFinder.ts +++ b/src/cli/entrypoint/finder/SidebarFinder.ts @@ -1,4 +1,4 @@ -import AbstractViewFinder from "./AbstractViewFinder"; +import ViewCspFinder from "./ViewCspFinder"; import PluginFinder from "./PluginFinder"; import {SidebarParser} from "../parser"; @@ -7,7 +7,7 @@ import {ReadonlyConfig} from "@typing/config"; import {SidebarEntrypointOptions} from "@typing/sidebar"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends AbstractViewFinder { +export default class extends ViewCspFinder { constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/finder/ViewCspFinder.test.ts b/src/cli/entrypoint/finder/ViewCspFinder.test.ts new file mode 100644 index 0000000..915801d --- /dev/null +++ b/src/cli/entrypoint/finder/ViewCspFinder.test.ts @@ -0,0 +1,46 @@ +import path from "path"; + +import Page from "@cli/plugins/page/Page"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "tests", "fixtures", "view-csp"); + +const config = { + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + mergePages: true, + plugins: [ + { + name: path.join(rootDir, "src"), + page: "page.ts", + }, + ], + rootDir, + sharedDir: ".", + srcDir: "src", +} as ReadonlyConfig; + +describe("ViewCspFinder", () => { + test("collects CSP for manifest and keeps it out of HTML tag options", async () => { + const page = new Page(config); + + await expect(page.csp()).resolves.toEqual([ + { + sources: { + connect: ["https://api.example.com"], + }, + }, + ]); + + await expect(page.view().tags()).resolves.toEqual([ + { + files: ["page.html"], + name: "help", + }, + ]); + }); +}); diff --git a/src/cli/entrypoint/finder/ViewCspFinder.ts b/src/cli/entrypoint/finder/ViewCspFinder.ts new file mode 100644 index 0000000..0a82121 --- /dev/null +++ b/src/cli/entrypoint/finder/ViewCspFinder.ts @@ -0,0 +1,31 @@ +import AbstractViewFinder, {ViewItems} from "./AbstractViewFinder"; + +import type {ViewCspEntrypointOptions} from "../parser/ViewCspParser"; + +import type {CspConfig} from "@typing/csp"; + +export default abstract class extends AbstractViewFinder { + protected async getViews(): Promise> { + const views = await super.getViews(); + + for (const view of views.values()) { + const {csp, ...options} = view.options; + + view.options = options as O; + } + + return views; + } + + public async csp(): Promise { + const policies: CspConfig[] = []; + + for (const [, options] of await this.plugin().options()) { + if (options.csp) { + policies.push(options.csp); + } + } + + return policies; + } +} diff --git a/src/cli/entrypoint/finder/index.ts b/src/cli/entrypoint/finder/index.ts index 0b0ea96..dc862c9 100644 --- a/src/cli/entrypoint/finder/index.ts +++ b/src/cli/entrypoint/finder/index.ts @@ -8,6 +8,7 @@ export { type ViewAliasToFilename, type ViewFileToFilename, } from "./AbstractViewFinder"; +export {default as ViewCspFinder} from "./ViewCspFinder"; export {default as AbstractOptionsFinder} from "./AbstractOptionsFinder"; export {default as BackgroundFinder} from "./BackgroundFinder"; export {default as CommandFinder} from "./CommandFinder"; diff --git a/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts b/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts new file mode 100644 index 0000000..13a4deb --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts @@ -0,0 +1,10 @@ +import {definePage} from "adnbn"; + +export default definePage({ + name: "help", + csp: { + sources: { + connect: ["https://api.example.com"], + }, + }, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json new file mode 100644 index 0000000..36aa1a4 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/src/cli/entrypoint/parser/OffscreenParser.ts b/src/cli/entrypoint/parser/OffscreenParser.ts index 4c50fc9..0f6dcb0 100644 --- a/src/cli/entrypoint/parser/OffscreenParser.ts +++ b/src/cli/entrypoint/parser/OffscreenParser.ts @@ -1,10 +1,10 @@ import z from "zod"; -import ViewParser from "./ViewParser"; +import ViewCspParser from "./ViewCspParser"; import {OffscreenEntrypointOptions, OffscreenReason} from "@typing/offscreen"; -export default class extends ViewParser { +export default class extends ViewCspParser { protected definition(): string { return "defineOffscreen"; } diff --git a/src/cli/entrypoint/parser/PageParser.test.ts b/src/cli/entrypoint/parser/PageParser.test.ts new file mode 100644 index 0000000..667f268 --- /dev/null +++ b/src/cli/entrypoint/parser/PageParser.test.ts @@ -0,0 +1,39 @@ +import path from "path"; + +import PageParser from "./PageParser"; + +import type {ReadonlyConfig} from "@typing/config"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "page"); + +const parser = new PageParser({rootDir} as ReadonlyConfig); + +const file = (...parts: string[]) => { + const filename = path.join(fixtures, ...parts); + + return { + file: filename, + import: filename, + }; +}; + +const parseOptions = (...parts: string[]) => parser.options(file(...parts)); + +describe("PageParser", () => { + test("parses view CSP options from a real entrypoint file", () => { + expect(parseOptions("options", "csp", "page.ts")).toEqual({ + name: "help", + csp: { + wasm: true, + sources: { + connect: ["'self'", "https://api.example.com"], + image: ["'self'", "data:", "blob:"], + style: ["'self'", "'unsafe-inline'"], + worker: ["blob:"], + frame: ["https://frame.example.com"], + }, + }, + }); + }); +}); diff --git a/src/cli/entrypoint/parser/PageParser.ts b/src/cli/entrypoint/parser/PageParser.ts index d6b849b..d19a48a 100644 --- a/src/cli/entrypoint/parser/PageParser.ts +++ b/src/cli/entrypoint/parser/PageParser.ts @@ -1,10 +1,10 @@ import z from "zod"; -import ViewParser from "./ViewParser"; +import ViewCspParser from "./ViewCspParser"; import {PageEntrypointOptions} from "@typing/page"; -export default class extends ViewParser { +export default class extends ViewCspParser { protected definition(): string { return "definePage"; } diff --git a/src/cli/entrypoint/parser/PopupParser.ts b/src/cli/entrypoint/parser/PopupParser.ts index 6f6cd3b..24f0412 100644 --- a/src/cli/entrypoint/parser/PopupParser.ts +++ b/src/cli/entrypoint/parser/PopupParser.ts @@ -1,10 +1,10 @@ import {z} from "zod"; -import ViewParser from "./ViewParser"; +import ViewCspParser from "./ViewCspParser"; import {PopupEntrypointOptions} from "@typing/popup"; -export default class extends ViewParser { +export default class extends ViewCspParser { protected definition(): string { return "definePopup"; } diff --git a/src/cli/entrypoint/parser/SidebarParser.ts b/src/cli/entrypoint/parser/SidebarParser.ts index ebc588a..fe35775 100644 --- a/src/cli/entrypoint/parser/SidebarParser.ts +++ b/src/cli/entrypoint/parser/SidebarParser.ts @@ -1,10 +1,10 @@ import {z} from "zod"; -import ViewParser from "./ViewParser"; +import ViewCspParser from "./ViewCspParser"; import {SidebarEntrypointOptions} from "@typing/sidebar"; -export default class extends ViewParser { +export default class extends ViewCspParser { protected definition(): string { return "defineSidebar"; } diff --git a/src/cli/entrypoint/parser/ViewCspParser.ts b/src/cli/entrypoint/parser/ViewCspParser.ts new file mode 100644 index 0000000..68e7427 --- /dev/null +++ b/src/cli/entrypoint/parser/ViewCspParser.ts @@ -0,0 +1,34 @@ +import {z} from "zod"; + +import ViewParser from "./ViewParser"; + +import type {CspOptions} from "@typing/csp"; +import type {ViewEntrypointOptions} from "@typing/view"; + +export type ViewCspEntrypointOptions = ViewEntrypointOptions & CspOptions; + +export default abstract class extends ViewParser { + protected cspSourcesSchema = z.array(z.string()).optional(); + + protected schema(): typeof this.CommonPropertiesSchema { + return super.schema().extend({ + csp: z + .object({ + wasm: z.boolean().optional(), + sources: z + .object({ + connect: this.cspSourcesSchema, + image: this.cspSourcesSchema, + style: this.cspSourcesSchema, + font: this.cspSourcesSchema, + media: this.cspSourcesSchema, + worker: this.cspSourcesSchema, + child: this.cspSourcesSchema, + frame: this.cspSourcesSchema, + }) + .optional(), + }) + .optional(), + }); + } +} diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts b/src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts new file mode 100644 index 0000000..3500e9b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts @@ -0,0 +1,15 @@ +import {CspSource, definePage} from "adnbn"; + +export default definePage({ + name: "help", + csp: { + wasm: true, + sources: { + connect: [CspSource.Self, "https://api.example.com"], + image: [CspSource.Self, CspSource.Data, CspSource.Blob], + style: [CspSource.Self, CspSource.UnsafeInline], + worker: [CspSource.Blob], + frame: ["https://frame.example.com"], + }, + }, +}); diff --git a/src/cli/plugins/offscreen/index.ts b/src/cli/plugins/offscreen/index.ts index 2d5861c..d729a2b 100644 --- a/src/cli/plugins/offscreen/index.ts +++ b/src/cli/plugins/offscreen/index.ts @@ -98,6 +98,8 @@ export default definePlugin(() => { } satisfies RspackConfig; }, manifest: async ({manifest, config}) => { + manifest.appendCsp(await offscreen.views().csp()); + if (config.manifestVersion !== 2 && config.browser !== Browser.Firefox && (await offscreen.exists())) { manifest.addPermission("offscreen"); } diff --git a/src/cli/plugins/page/index.ts b/src/cli/plugins/page/index.ts index 5fcd499..0acbba8 100644 --- a/src/cli/plugins/page/index.ts +++ b/src/cli/plugins/page/index.ts @@ -65,7 +65,7 @@ export default definePlugin(() => { } satisfies RspackConfig; }, manifest: async ({manifest}) => { - manifest.appendAccessibleResources(await page.accessibleResources()); + manifest.appendAccessibleResources(await page.accessibleResources()).appendCsp(await page.csp()); }, }; }); diff --git a/src/cli/plugins/popup/index.ts b/src/cli/plugins/popup/index.ts index 9de5a28..c78c721 100644 --- a/src/cli/plugins/popup/index.ts +++ b/src/cli/plugins/popup/index.ts @@ -65,7 +65,7 @@ export default definePlugin(() => { } as RspackConfig; }, manifest: async ({manifest}) => { - manifest.setPopup(await popup.manifest()); + manifest.setPopup(await popup.manifest()).appendCsp(await popup.csp()); }, }; }); diff --git a/src/cli/plugins/sandbox/Sandbox.ts b/src/cli/plugins/sandbox/Sandbox.ts index 1ecea51..4008ffe 100644 --- a/src/cli/plugins/sandbox/Sandbox.ts +++ b/src/cli/plugins/sandbox/Sandbox.ts @@ -3,10 +3,8 @@ import {View} from "../view"; import {SandboxFinder, SandboxViewFinder} from "@cli/entrypoint"; import {virtualSandboxModule} from "@cli/virtual"; -import SandboxCsp from "./SandboxCsp"; - import {EntrypointFile} from "@typing/entrypoint"; -import {SandboxEntrypointOptions, SandboxParameters} from "@typing/sandbox"; +import {SandboxCspConfig, SandboxEntrypointOptions, SandboxParameters} from "@typing/sandbox"; export type SandboxParametersMap = Record; @@ -61,14 +59,14 @@ export default class extends SandboxFinder { return Object.values(await this.parameters()).map(({url}) => url); } - public async contentSecurityPolicy(): Promise { - const csp = new SandboxCsp(); + public async csp(): Promise { + const csps: SandboxCspConfig[] = []; for (const {options} of (await this.transport()).values()) { - csp.add(options.csp); + csps.push(options.csp || {}); } - return csp.build(); + return csps; } public clear(): this { diff --git a/src/cli/plugins/sandbox/SandboxCsp.ts b/src/cli/plugins/sandbox/SandboxCsp.ts deleted file mode 100644 index 7aec426..0000000 --- a/src/cli/plugins/sandbox/SandboxCsp.ts +++ /dev/null @@ -1,81 +0,0 @@ -import {SandboxAllow, SandboxContentSecurityPolicy, SandboxSource} from "@typing/sandbox"; - -const SourceDirectives: Record, string> = { - connect: "connect-src", - image: "img-src", - style: "style-src", - font: "font-src", - media: "media-src", - worker: "worker-src", - child: "child-src", -}; - -export default class SandboxCsp { - private eval = false; - private inline = false; - private readonly allow = new Set(); - private readonly sources: Map> = new Map(); - - public add(csp?: SandboxContentSecurityPolicy): this { - csp = { - eval: true, - inline: false, - allow: [], - sources: { - child: [SandboxSource.Self], - }, - ...csp, - }; - - if (csp.eval) { - this.eval = true; - } - - if (csp.inline) { - this.inline = true; - } - - for (const value of csp.allow || []) { - this.allow.add(value); - } - - for (const [key, values] of Object.entries(csp.sources || {})) { - const directive = SourceDirectives[key as keyof typeof SourceDirectives]; - - if (!directive || !values) { - continue; - } - - const source = this.sources.get(directive) ?? new Set(); - - for (const value of values) { - source.add(value); - } - - this.sources.set(directive, source); - } - - return this; - } - - public build(): string { - const sandbox = ["sandbox", "allow-scripts", ...Array.from(this.allow).map(value => `allow-${value}`)]; - const script = ["script-src", SandboxSource.Self]; - - if (this.eval) { - script.push("'unsafe-eval'" as SandboxSource); - } - - if (this.inline) { - script.push(SandboxSource.UnsafeInline); - } - - const directives = [sandbox, script]; - - for (const [directive, values] of this.sources) { - directives.push([directive, ...values]); - } - - return directives.map(parts => `${parts.join(" ")};`).join(" "); - } -} diff --git a/src/cli/plugins/sandbox/index.ts b/src/cli/plugins/sandbox/index.ts index 7699c56..366863a 100644 --- a/src/cli/plugins/sandbox/index.ts +++ b/src/cli/plugins/sandbox/index.ts @@ -68,9 +68,7 @@ export default definePlugin(() => { }, manifest: async ({manifest}) => { if (await sandbox.exists()) { - manifest - .appendSandboxes(await sandbox.sandboxes()) - .setSandboxContentSecurityPolicy(await sandbox.contentSecurityPolicy()); + manifest.appendSandboxes(await sandbox.sandboxes()).appendSandboxCsp(await sandbox.csp()); } }, }; diff --git a/src/cli/plugins/sidebar/index.ts b/src/cli/plugins/sidebar/index.ts index 56cece5..64e5d10 100644 --- a/src/cli/plugins/sidebar/index.ts +++ b/src/cli/plugins/sidebar/index.ts @@ -87,6 +87,7 @@ export default definePlugin(() => { } manifest.setSidebar(await sidebar.manifest()); + manifest.appendCsp(await sidebar.csp()); if ((await sidebar.exists()) && !SidebarAlternativeBrowsers.has(config.browser)) { manifest.addPermission("sidePanel"); diff --git a/src/main/csp.ts b/src/main/csp.ts new file mode 100644 index 0000000..ca7cd15 --- /dev/null +++ b/src/main/csp.ts @@ -0,0 +1,2 @@ +export type {CspConfig, CspOptions, CspSources, CspSourceValue} from "@typing/csp"; +export {CspSource} from "@typing/csp"; diff --git a/src/main/index.ts b/src/main/index.ts index c5637f8..0966717 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,6 +4,7 @@ export * from "./browser"; export * from "./command"; export * from "./config"; export * from "./content"; +export * from "./csp"; export * from "./env"; export * from "./icon"; export * from "./manifest"; diff --git a/src/types/csp.ts b/src/types/csp.ts new file mode 100644 index 0000000..4231614 --- /dev/null +++ b/src/types/csp.ts @@ -0,0 +1,38 @@ +export enum CspSource { + Self = "'self'", + None = "'none'", + Data = "data:", + Blob = "blob:", + UnsafeInline = "'unsafe-inline'", +} + +/** + * Source expression for a CSP directive. + * + * Use `CspSource` or its string values for framework-known keywords and schemes + * such as `'self'`, `'none'`, `data:`, or `blob:`. The open string branch is for + * concrete CSP source expressions that cannot be enumerated ahead of time, such + * as `https://api.example.com`, `wss://socket.example.com`, `https:`, `nonce-...`, + * or `sha256-...`. It does not mean an empty string is useful or valid. + */ +export type CspSourceValue = CspSource | `${CspSource}` | (string & Record); + +export interface CspSources { + connect?: CspSourceValue[]; + image?: CspSourceValue[]; + style?: CspSourceValue[]; + font?: CspSourceValue[]; + media?: CspSourceValue[]; + worker?: CspSourceValue[]; + child?: CspSourceValue[]; + frame?: CspSourceValue[]; +} + +export interface CspConfig { + wasm?: boolean; + sources?: CspSources; +} + +export interface CspOptions { + csp?: CspConfig; +} diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 2ab57d5..1c9d9a7 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -3,6 +3,8 @@ import {BackgroundConfig} from "@typing/background"; import {CommandConfig} from "@typing/command"; import {Language} from "@typing/locale"; import {BrowserSpecific, DataCollectionPermission} from "@typing/browser"; +import {CspConfig} from "@typing/csp"; +import {SandboxCspConfig} from "@typing/sandbox"; type ManifestCommon = chrome.runtime.Manifest; type ManifestBase = chrome.runtime.ManifestBase; @@ -131,7 +133,14 @@ export interface ManifestBuilder { appendSandboxes(sandboxes: Iterable): this; - setSandboxContentSecurityPolicy(policy?: ManifestSandboxContentSecurityPolicy): this; + addSandboxCsp(csp: SandboxCspConfig): this; + + appendSandboxCsp(csps: Iterable): this; + + // Content Security Policy + addCsp(csp: CspConfig): this; + + appendCsp(csps: Iterable): this; // System setDependencies(dependencies: ManifestDependencies): this; @@ -240,8 +249,6 @@ export type ManifestSandbox = string; export type ManifestSandboxes = Set; -export type ManifestSandboxContentSecurityPolicy = string; - export interface ManifestDependency { js: Set; css: Set; diff --git a/src/types/offscreen.ts b/src/types/offscreen.ts index ceeda78..2ec93a0 100644 --- a/src/types/offscreen.ts +++ b/src/types/offscreen.ts @@ -1,6 +1,7 @@ import {TransportConfig, TransportDefinition, TransportType} from "@typing/transport"; import {ViewOptions} from "@typing/view"; import {Awaiter} from "@typing/helpers"; +import {CspOptions} from "@typing/csp"; export const OffscreenGlobalKey = "adnbnOffscreen"; export const OffscreenGlobalAccess = "adnbnOffscreenAccess"; @@ -44,7 +45,7 @@ export interface OffscreenConfig extends TransportConfig { justification?: string; } -export type OffscreenOptions = OffscreenConfig & ViewOptions; +export type OffscreenOptions = OffscreenConfig & CspOptions & ViewOptions; export type OffscreenEntrypointOptions = Partial; diff --git a/src/types/page.ts b/src/types/page.ts index b85a778..0dd89a0 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -1,11 +1,12 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; +import {CspOptions} from "@typing/csp"; export interface PageConfig { name?: string; matches?: string[]; } -export type PageEntrypointOptions = PageConfig & ViewOptions; +export type PageEntrypointOptions = PageConfig & CspOptions & ViewOptions; export type PageProps = PageEntrypointOptions; diff --git a/src/types/popup.ts b/src/types/popup.ts index 9b55d61..de269ce 100644 --- a/src/types/popup.ts +++ b/src/types/popup.ts @@ -1,11 +1,12 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; +import {CspOptions} from "@typing/csp"; export interface PopupConfig { icon?: string; apply?: boolean; } -export type PopupEntrypointOptions = PopupConfig & ViewOptions; +export type PopupEntrypointOptions = PopupConfig & CspOptions & ViewOptions; export type PopupProps = PopupEntrypointOptions; diff --git a/src/types/sandbox.ts b/src/types/sandbox.ts index 6240acf..59f9e46 100644 --- a/src/types/sandbox.ts +++ b/src/types/sandbox.ts @@ -27,7 +27,7 @@ export enum SandboxSource { UnsafeInline = "'unsafe-inline'", } -export interface SandboxContentSecurityPolicySources { +export interface SandboxCspSources { connect?: Array; image?: Array; style?: Array; @@ -37,21 +37,24 @@ export interface SandboxContentSecurityPolicySources { child?: Array; } -export interface SandboxContentSecurityPolicy { +export interface SandboxCspConfig { eval?: boolean; inline?: boolean; allow?: Array; - sources?: SandboxContentSecurityPolicySources; + sources?: SandboxCspSources; +} + +export interface SandboxCspOptions { + csp?: SandboxCspConfig; } export interface SandboxConfig extends TransportConfig { - csp?: SandboxContentSecurityPolicy; readyTimeout?: number; requestTimeout?: number; removeOnRequestTimeout?: boolean; } -export type SandboxOptions = SandboxConfig & ViewOptions; +export type SandboxOptions = SandboxConfig & SandboxCspOptions & ViewOptions; export type SandboxEntrypointOptions = Partial; diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index 595791d..c9731c8 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -1,5 +1,6 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {Browser} from "@typing/browser"; +import {CspOptions} from "@typing/csp"; export const SidebarAlternativeBrowsers: ReadonlySet = new Set([Browser.Opera, Browser.Firefox]); @@ -8,7 +9,7 @@ export interface SidebarConfig { apply?: boolean; } -export type SidebarEntrypointOptions = SidebarConfig & ViewOptions; +export type SidebarEntrypointOptions = SidebarConfig & CspOptions & ViewOptions; export type SidebarProps = SidebarEntrypointOptions; From 06f2a3b34aa2e0e7be310e96b540e46cc7b4c88a Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sun, 24 May 2026 15:35:02 +0300 Subject: [PATCH 08/10] refactor(csp): add sandbox CSP support and integrate with view finders - Implement `SandboxViewFinder` extending `ViewCspFinder` to handle sandbox CSPs. - Add `sandbox.ts` fixture to define sandbox CSP configurations. - Update `Sandbox` to fetch CSPs using view-based methods. - Adjust manifest to include sandbox CSPs via updated view logic. - Add tests to validate sandbox CSP collection and integration. --- src/cli/entrypoint/finder/SandboxViewFinder.ts | 8 +++++--- src/cli/entrypoint/finder/ViewCspFinder.test.ts | 16 ++++++++++++++++ src/cli/entrypoint/finder/ViewCspFinder.ts | 13 +++++++------ .../tests/fixtures/view-csp/src/sandbox.ts | 13 +++++++++++++ src/cli/plugins/sandbox/Sandbox.ts | 12 +----------- src/cli/plugins/sandbox/index.ts | 2 +- 6 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts diff --git a/src/cli/entrypoint/finder/SandboxViewFinder.ts b/src/cli/entrypoint/finder/SandboxViewFinder.ts index 4090e10..81cfd49 100644 --- a/src/cli/entrypoint/finder/SandboxViewFinder.ts +++ b/src/cli/entrypoint/finder/SandboxViewFinder.ts @@ -1,11 +1,13 @@ -import AbstractViewFinder, {ViewItems} from "./AbstractViewFinder"; import AbstractTransportFinder from "./AbstractTransportFinder"; +import ViewCspFinder from "./ViewCspFinder"; + +import {ViewItems} from "./AbstractViewFinder"; import {ReadonlyConfig} from "@typing/config"; -import {SandboxEntrypointOptions} from "@typing/sandbox"; +import {SandboxCspConfig, SandboxEntrypointOptions} from "@typing/sandbox"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends AbstractViewFinder { +export default class extends ViewCspFinder { constructor( config: ReadonlyConfig, protected readonly finder: AbstractTransportFinder diff --git a/src/cli/entrypoint/finder/ViewCspFinder.test.ts b/src/cli/entrypoint/finder/ViewCspFinder.test.ts index 915801d..a8fff3d 100644 --- a/src/cli/entrypoint/finder/ViewCspFinder.test.ts +++ b/src/cli/entrypoint/finder/ViewCspFinder.test.ts @@ -1,6 +1,7 @@ import path from "path"; import Page from "@cli/plugins/page/Page"; +import {SandboxFinder, SandboxViewFinder} from "@cli/entrypoint"; import type {ReadonlyConfig} from "@typing/config"; @@ -17,6 +18,7 @@ const config = { { name: path.join(rootDir, "src"), page: "page.ts", + sandbox: "sandbox.ts", }, ], rootDir, @@ -43,4 +45,18 @@ describe("ViewCspFinder", () => { }, ]); }); + + test("collects sandbox CSP through sandbox view finder", async () => { + const sandbox = new SandboxViewFinder(config, new SandboxFinder(config)); + + await expect(sandbox.csp()).resolves.toEqual([ + { + inline: true, + allow: ["forms"], + sources: { + worker: ["blob:"], + }, + }, + ]); + }); }); diff --git a/src/cli/entrypoint/finder/ViewCspFinder.ts b/src/cli/entrypoint/finder/ViewCspFinder.ts index 0a82121..bb42bcf 100644 --- a/src/cli/entrypoint/finder/ViewCspFinder.ts +++ b/src/cli/entrypoint/finder/ViewCspFinder.ts @@ -1,10 +1,11 @@ import AbstractViewFinder, {ViewItems} from "./AbstractViewFinder"; -import type {ViewCspEntrypointOptions} from "../parser/ViewCspParser"; - import type {CspConfig} from "@typing/csp"; +import type {ViewEntrypointOptions} from "@typing/view"; + +type CspEntrypointOptions = ViewEntrypointOptions & {csp?: unknown}; -export default abstract class extends AbstractViewFinder { +export default abstract class extends AbstractViewFinder { protected async getViews(): Promise> { const views = await super.getViews(); @@ -17,12 +18,12 @@ export default abstract class extends Abstra return views; } - public async csp(): Promise { - const policies: CspConfig[] = []; + public async csp(): Promise { + const policies: Csp[] = []; for (const [, options] of await this.plugin().options()) { if (options.csp) { - policies.push(options.csp); + policies.push(options.csp as Csp); } } diff --git a/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts b/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts new file mode 100644 index 0000000..7a55961 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts @@ -0,0 +1,13 @@ +import {defineSandbox, SandboxAllow, SandboxSource} from "adnbn"; + +export default defineSandbox({ + name: "cspSandbox", + csp: { + inline: true, + allow: [SandboxAllow.Forms], + sources: { + worker: [SandboxSource.Blob], + }, + }, + init: () => ({}), +}); diff --git a/src/cli/plugins/sandbox/Sandbox.ts b/src/cli/plugins/sandbox/Sandbox.ts index 4008ffe..6277dc9 100644 --- a/src/cli/plugins/sandbox/Sandbox.ts +++ b/src/cli/plugins/sandbox/Sandbox.ts @@ -4,7 +4,7 @@ import {SandboxFinder, SandboxViewFinder} from "@cli/entrypoint"; import {virtualSandboxModule} from "@cli/virtual"; import {EntrypointFile} from "@typing/entrypoint"; -import {SandboxCspConfig, SandboxEntrypointOptions, SandboxParameters} from "@typing/sandbox"; +import {SandboxEntrypointOptions, SandboxParameters} from "@typing/sandbox"; export type SandboxParametersMap = Record; @@ -59,16 +59,6 @@ export default class extends SandboxFinder { return Object.values(await this.parameters()).map(({url}) => url); } - public async csp(): Promise { - const csps: SandboxCspConfig[] = []; - - for (const {options} of (await this.transport()).values()) { - csps.push(options.csp || {}); - } - - return csps; - } - public clear(): this { this._view = undefined; this._views = undefined; diff --git a/src/cli/plugins/sandbox/index.ts b/src/cli/plugins/sandbox/index.ts index 366863a..a4c2b7c 100644 --- a/src/cli/plugins/sandbox/index.ts +++ b/src/cli/plugins/sandbox/index.ts @@ -68,7 +68,7 @@ export default definePlugin(() => { }, manifest: async ({manifest}) => { if (await sandbox.exists()) { - manifest.appendSandboxes(await sandbox.sandboxes()).appendSandboxCsp(await sandbox.csp()); + manifest.appendSandboxes(await sandbox.sandboxes()).appendSandboxCsp(await sandbox.views().csp()); } }, }; From 8de9e0af9696fe85a70212029a72635fadb357c2 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 25 May 2026 14:41:05 +0300 Subject: [PATCH 09/10] fix(page): add sandbox entrypoint support to PageFinder --- src/cli/entrypoint/finder/PageFinder.test.ts | 93 ++++++++++++++++++++ src/cli/entrypoint/finder/PageFinder.ts | 1 + 2 files changed, 94 insertions(+) create mode 100644 src/cli/entrypoint/finder/PageFinder.test.ts diff --git a/src/cli/entrypoint/finder/PageFinder.test.ts b/src/cli/entrypoint/finder/PageFinder.test.ts new file mode 100644 index 0000000..5348641 --- /dev/null +++ b/src/cli/entrypoint/finder/PageFinder.test.ts @@ -0,0 +1,93 @@ +import PageFinder from "./PageFinder"; + +import {EntrypointFile, EntrypointOptionsFinder, EntrypointType} from "@typing/entrypoint"; +import type {PageEntrypointOptions} from "@typing/page"; +import type {ReadonlyConfig} from "@typing/config"; + +class ExposedPageFinder extends PageFinder { + public constructor( + config: ReadonlyConfig, + private readonly pluginOptions?: Map + ) { + super(config); + } + + public plugin(): EntrypointOptionsFinder { + return this.pluginOptions ? createPlugin(this.pluginOptions) : super.plugin(); + } + + public aliasFrom(file: EntrypointFile, options: PageEntrypointOptions): string { + return this.createViewAlias(file, options); + } +} + +const makeConfig = (overrides: Partial = {}) => + ({ + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + mergePages: true, + plugins: [], + rootDir: "/project", + sharedDir: ".", + srcDir: "src", + ...overrides, + }) as ReadonlyConfig; + +const config = makeConfig(); + +const file = (name: string): EntrypointFile => ({ + file: `/project/src/${name}`, + import: `@/${name}`, +}); + +const createPlugin = ( + options: Map +): EntrypointOptionsFinder => ({ + type: () => EntrypointType.Page, + options: async () => options, + contracts: async () => new Map(Array.from(options.keys()).map(entry => [entry, undefined])), + files: async () => new Set(options.keys()), + empty: async () => options.size === 0, + exists: async () => options.size > 0, + clear: function () { + return this; + }, + holds: entry => options.has(entry), +}); + +describe("PageFinder", () => { + test("follows mergePages from config", () => { + expect(new PageFinder(makeConfig({mergePages: true})).canMerge()).toBe(true); + expect(new PageFinder(makeConfig({mergePages: false})).canMerge()).toBe(false); + }); + + test("keeps page filenames away from reserved entrypoint output", async () => { + const page = new ExposedPageFinder(config, new Map([[file("sandbox.ts"), {as: "sandbox"}]])); + + await expect(page.views()).resolves.toMatchObject(new Map([["sandbox.page", {filename: "sandbox1.html"}]])); + }); + + test("uses page name as an alias when it is defined", () => { + expect(new ExposedPageFinder(config).aliasFrom(file("named.ts"), {name: "docs"})).toBe("docs"); + }); + + test("uses filename as an alias when page name is absent", () => { + expect(new ExposedPageFinder(config).aliasFrom(file("plain.page.ts"), {})).toBe("plain"); + }); + + test("keeps external page import as alias", () => { + expect( + new ExposedPageFinder(config).aliasFrom( + { + file: "/project/node_modules/external-page/page.ts", + import: "external-page/page", + external: "external-page", + }, + {name: "ignored"} + ) + ).toBe("external-page/page"); + }); +}); diff --git a/src/cli/entrypoint/finder/PageFinder.ts b/src/cli/entrypoint/finder/PageFinder.ts index 5a7dfca..be0d5b9 100644 --- a/src/cli/entrypoint/finder/PageFinder.ts +++ b/src/cli/entrypoint/finder/PageFinder.ts @@ -31,6 +31,7 @@ export default class extends ViewCspFinder { .reserve(EntrypointType.Sidebar) .reserve(EntrypointType.Popup) .reserve(EntrypointType.Offscreen) + .reserve(EntrypointType.Sandbox) .reserve(EntrypointType.Options); } From b7695da4480e70b65c71d7216141d77563596e00 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 25 May 2026 14:42:10 +0300 Subject: [PATCH 10/10] test: add unit tests for locale validation and name generator refactor --- .../entrypoint/finder/LocaleFinder.test.ts | 27 +--- .../entrypoint/finder/ViewCspFinder.test.ts | 119 +++++++++++++----- .../locale/default-missing/locales/fr.yaml | 2 - .../fixtures/locale/empty/locales/.gitkeep | 0 .../fixtures/locale/extra-key/locales/en.yaml | 2 - .../fixtures/locale/extra-key/locales/fr.yaml | 3 - .../locale/plural-mismatch/locales/en.yaml | 4 - .../locale/plural-mismatch/locales/fr.yaml | 2 - .../tests/fixtures/view-csp/src/page.ts | 10 -- .../tests/fixtures/view-csp/src/sandbox.ts | 13 -- .../tests/fixtures/view-csp/tsconfig.json | 5 - .../name/InlineNameGenerator.test.ts | 50 ++++++++ src/cli/entrypoint/name/NameGenerator.test.ts | 71 +++++++++++ 13 files changed, 208 insertions(+), 100 deletions(-) delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/default-missing/locales/fr.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/empty/locales/.gitkeep delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/en.yaml delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/fr.yaml delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/en.yaml delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/fr.yaml delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts delete mode 100644 src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json create mode 100644 src/cli/entrypoint/name/InlineNameGenerator.test.ts create mode 100644 src/cli/entrypoint/name/NameGenerator.test.ts diff --git a/src/cli/entrypoint/finder/LocaleFinder.test.ts b/src/cli/entrypoint/finder/LocaleFinder.test.ts index c7fa539..cb2cc62 100644 --- a/src/cli/entrypoint/finder/LocaleFinder.test.ts +++ b/src/cli/entrypoint/finder/LocaleFinder.test.ts @@ -147,34 +147,9 @@ describe("LocaleFinder", () => { }); }); - test("requires configured default locale to exist when translations are present", async () => { - await expect(makeFinder("default-missing").validate()).rejects.toThrow( - 'Default locale "en" not found in available translations. Available languages: fr' - ); - }); - - test("warns about keys outside the default locale contract", async () => { - const consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); - - try { - await expect(makeFinder("extra-key").validate()).resolves.toBeInstanceOf(LocaleFinder); - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Locale "fr" contains unknown key "app.extra" not found in default locale "en"' - ); - } finally { - consoleWarnSpy.mockRestore(); - } - }); - - test("rejects substitution mismatch against the default locale contract", async () => { + test("surfaces structure validation errors from real locale files", async () => { await expect(makeFinder("substitution-mismatch").validate()).rejects.toThrow( 'Locale "fr" key "app.greeting" substitutions [firstName] must match default locale "en" substitutions [name]' ); }); - - test("rejects plural mismatch against the default locale contract", async () => { - await expect(makeFinder("plural-mismatch").validate()).rejects.toThrow( - 'Locale "fr" key "app.cars" must be plural like default locale "en"' - ); - }); }); diff --git a/src/cli/entrypoint/finder/ViewCspFinder.test.ts b/src/cli/entrypoint/finder/ViewCspFinder.test.ts index a8fff3d..291155c 100644 --- a/src/cli/entrypoint/finder/ViewCspFinder.test.ts +++ b/src/cli/entrypoint/finder/ViewCspFinder.test.ts @@ -1,12 +1,10 @@ -import path from "path"; - -import Page from "@cli/plugins/page/Page"; -import {SandboxFinder, SandboxViewFinder} from "@cli/entrypoint"; +import ViewCspFinder from "./ViewCspFinder"; +import {EntrypointFile, EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; +import {CspConfig} from "@typing/csp"; +import type {ViewEntrypointOptions} from "@typing/view"; import type {ReadonlyConfig} from "@typing/config"; -const rootDir = path.resolve(__dirname, "tests", "fixtures", "view-csp"); - const config = { app: "app", appSrcDir: ".", @@ -14,21 +12,71 @@ const config = { debug: false, htmlDir: ".", mergePages: true, - plugins: [ - { - name: path.join(rootDir, "src"), - page: "page.ts", - sandbox: "sandbox.ts", - }, - ], - rootDir, + plugins: [], + rootDir: "/project", sharedDir: ".", srcDir: "src", -} as ReadonlyConfig; +} as Partial as ReadonlyConfig; + +type CspViewOptions = ViewEntrypointOptions & {csp?: CspConfig}; + +class TestViewCspFinder extends ViewCspFinder { + public constructor( + config: ReadonlyConfig, + private readonly pluginOptions: Map + ) { + super(config); + } + + public type(): EntrypointType { + return EntrypointType.Page; + } + + protected getParser(): EntrypointParser { + throw new Error("Parser should not be used in ViewCspFinder tests."); + } + + protected getPlugin(): EntrypointOptionsFinder { + return createPlugin(this.pluginOptions); + } +} + +const file = (name: string): EntrypointFile => ({ + file: `/project/src/${name}`, + import: `@/${name}`, +}); + +const createPlugin = (options: Map): EntrypointOptionsFinder => ({ + type: () => EntrypointType.Page, + options: async () => options, + contracts: async () => new Map(Array.from(options.keys()).map(entry => [entry, undefined])), + files: async () => new Set(options.keys()), + empty: async () => options.size === 0, + exists: async () => options.size > 0, + clear: function () { + return this; + }, + holds: entry => options.has(entry), +}); describe("ViewCspFinder", () => { test("collects CSP for manifest and keeps it out of HTML tag options", async () => { - const page = new Page(config); + const page = new TestViewCspFinder( + config, + new Map([ + [ + file("page.ts"), + { + title: "Help", + csp: { + sources: { + connect: ["https://api.example.com"], + }, + }, + }, + ], + ]) + ); await expect(page.csp()).resolves.toEqual([ { @@ -38,25 +86,30 @@ describe("ViewCspFinder", () => { }, ]); - await expect(page.view().tags()).resolves.toEqual([ - { - files: ["page.html"], - name: "help", - }, - ]); + await expect(page.views()).resolves.toMatchObject( + new Map([ + [ + "page", + { + options: { + title: "Help", + }, + }, + ], + ]) + ); + + const [view] = (await page.views()).values(); + + expect(view.options).not.toHaveProperty("csp"); }); - test("collects sandbox CSP through sandbox view finder", async () => { - const sandbox = new SandboxViewFinder(config, new SandboxFinder(config)); + test("ignores entries without CSP", async () => { + const page = new TestViewCspFinder( + config, + new Map([[file("page.ts"), {title: "Help"}]]) + ); - await expect(sandbox.csp()).resolves.toEqual([ - { - inline: true, - allow: ["forms"], - sources: { - worker: ["blob:"], - }, - }, - ]); + await expect(page.csp()).resolves.toEqual([]); }); }); diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/default-missing/locales/fr.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/default-missing/locales/fr.yaml deleted file mode 100644 index eb4797c..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/locale/default-missing/locales/fr.yaml +++ /dev/null @@ -1,2 +0,0 @@ -app: - name: Mon App diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/empty/locales/.gitkeep b/src/cli/entrypoint/finder/tests/fixtures/locale/empty/locales/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/en.yaml deleted file mode 100644 index 0b39a1a..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/en.yaml +++ /dev/null @@ -1,2 +0,0 @@ -app: - name: My App diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/fr.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/fr.yaml deleted file mode 100644 index 32be724..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/locale/extra-key/locales/fr.yaml +++ /dev/null @@ -1,3 +0,0 @@ -app: - name: Mon App - extra: Extra diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/en.yaml deleted file mode 100644 index 4cce302..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/en.yaml +++ /dev/null @@ -1,4 +0,0 @@ -app: - cars: - - car - - cars diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/fr.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/fr.yaml deleted file mode 100644 index cd37fcf..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/locale/plural-mismatch/locales/fr.yaml +++ /dev/null @@ -1,2 +0,0 @@ -app: - cars: voiture diff --git a/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts b/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts deleted file mode 100644 index 13a4deb..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/page.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {definePage} from "adnbn"; - -export default definePage({ - name: "help", - csp: { - sources: { - connect: ["https://api.example.com"], - }, - }, -}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts b/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts deleted file mode 100644 index 7a55961..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/view-csp/src/sandbox.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {defineSandbox, SandboxAllow, SandboxSource} from "adnbn"; - -export default defineSandbox({ - name: "cspSandbox", - csp: { - inline: true, - allow: [SandboxAllow.Forms], - sources: { - worker: [SandboxSource.Blob], - }, - }, - init: () => ({}), -}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json b/src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json deleted file mode 100644 index 36aa1a4..0000000 --- a/src/cli/entrypoint/finder/tests/fixtures/view-csp/tsconfig.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": "." - } -} diff --git a/src/cli/entrypoint/name/InlineNameGenerator.test.ts b/src/cli/entrypoint/name/InlineNameGenerator.test.ts new file mode 100644 index 0000000..8f78acc --- /dev/null +++ b/src/cli/entrypoint/name/InlineNameGenerator.test.ts @@ -0,0 +1,50 @@ +import InlineNameGenerator from "./InlineNameGenerator"; + +import {EntrypointFile, EntrypointType} from "@typing/entrypoint"; + +const file = (path: string): EntrypointFile => ({file: path, import: path}); + +describe("InlineNameGenerator", () => { + test("keeps names inline without the entrypoint suffix", () => { + expect(new InlineNameGenerator(EntrypointType.Page).name("help")).toBe("help"); + }); + + test("disambiguates colliding names with a numeric suffix", () => { + const generator = new InlineNameGenerator(EntrypointType.Page); + + expect(generator.name("help")).toBe("help"); + expect(generator.name("help")).toBe("help1"); + expect(generator.name("help")).toBe("help2"); + }); + + test("pushes names off reserved words", () => { + const generator = new InlineNameGenerator(EntrypointType.Page).reserve(EntrypointType.Sandbox); + + expect(generator.name("sandbox")).toBe("sandbox1"); + }); + + test("derives an inline name from a file", () => { + expect(new InlineNameGenerator(EntrypointType.Page).file(file("/project/src/help.page.ts"))).toBe("help"); + }); + + test("reset keeps reserved words but forgets generated names", () => { + const generator = new InlineNameGenerator(EntrypointType.Page).reserve(EntrypointType.Sandbox); + + expect(generator.name("sandbox")).toBe("sandbox1"); + expect(generator.name("help")).toBe("help"); + + generator.reset(); + + expect(generator.name("sandbox")).toBe("sandbox1"); + expect(generator.name("help")).toBe("help"); + }); + + test("likely matches the entrypoint type or names ending in a digit", () => { + const generator = new InlineNameGenerator(EntrypointType.Page); + + expect(generator.likely("page")).toBe(true); + expect(generator.likely("help1")).toBe(true); + expect(generator.likely("help")).toBe(false); + expect(generator.likely()).toBe(false); + }); +}); diff --git a/src/cli/entrypoint/name/NameGenerator.test.ts b/src/cli/entrypoint/name/NameGenerator.test.ts new file mode 100644 index 0000000..318f3ce --- /dev/null +++ b/src/cli/entrypoint/name/NameGenerator.test.ts @@ -0,0 +1,71 @@ +import NameGenerator from "./NameGenerator"; + +import {EntrypointFile, EntrypointType} from "@typing/entrypoint"; + +const file = (path: string): EntrypointFile => ({file: path, import: path}); + +describe("NameGenerator", () => { + test("suffixes a name with the entrypoint type", () => { + expect(new NameGenerator(EntrypointType.Page).name("docs")).toBe("docs.page"); + }); + + test("keeps a name equal to the entrypoint type unsuffixed", () => { + expect(new NameGenerator(EntrypointType.Page).name("page")).toBe("page"); + }); + + test("disambiguates colliding names with a counter", () => { + const generator = new NameGenerator(EntrypointType.Page); + + expect(generator.name("docs")).toBe("docs.page"); + expect(generator.name("docs")).toBe("docs1.page"); + expect(generator.name("docs")).toBe("docs2.page"); + }); + + test("disambiguates a name equal to the entrypoint type with a leading counter", () => { + const generator = new NameGenerator(EntrypointType.Page); + + expect(generator.name("page")).toBe("page"); + expect(generator.name("page")).toBe("1.page"); + }); + + test("derives a name from a plain file", () => { + expect(new NameGenerator(EntrypointType.Page).file(file("/project/src/docs.ts"))).toBe("docs.page"); + }); + + test("strips the entrypoint infix from a file name", () => { + expect(new NameGenerator(EntrypointType.Page).file(file("/project/src/docs.page.ts"))).toBe("docs.page"); + }); + + test("uses the directory name for index files", () => { + expect(new NameGenerator(EntrypointType.Page).file(file("/project/src/docs/index.ts"))).toBe("docs.page"); + }); + + test("skips reserved names", () => { + expect(new NameGenerator(EntrypointType.Page).reserve("docs.page").name("docs")).toBe("docs1.page"); + }); + + test("throws when a name is reserved twice", () => { + const generator = new NameGenerator(EntrypointType.Page).reserve("popup"); + + expect(() => generator.reserve("popup")).toThrow('Entrypoint name "popup" is already in use.'); + }); + + test("reset forgets generated names", () => { + const generator = new NameGenerator(EntrypointType.Page); + + expect(generator.name("docs")).toBe("docs.page"); + + generator.reset(); + + expect(generator.name("docs")).toBe("docs.page"); + }); + + test("likely matches entrypoint-shaped names", () => { + const generator = new NameGenerator(EntrypointType.Page); + + expect(generator.likely("page")).toBe(true); + expect(generator.likely("docs.page")).toBe(true); + expect(generator.likely("docs")).toBe(false); + expect(generator.likely()).toBe(false); + }); +});