@marimo-team/islands 0.23.3-dev8 → 0.23.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/{chat-ui-BLFhPclV.js → chat-ui-DEd_Ndal.js} +82 -82
  2. package/dist/{html-to-image-XYwXqg2E.js → html-to-image-DBosi5GK.js} +2240 -2214
  3. package/dist/main.js +2627 -2746
  4. package/dist/{process-output-BDVjDpbu.js → process-output-k-4WHpxz.js} +1 -1
  5. package/dist/{reveal-component-CrnLosc4.js → reveal-component-CFuofbBD.js} +827 -561
  6. package/dist/{slide-Dl7Rf496.js → slide-form-DgMI37ES.js} +1729 -894
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/components/editor/file-tree/renderers.tsx +1 -1
  10. package/src/components/editor/output/JsonOutput.tsx +187 -4
  11. package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
  12. package/src/components/editor/output/__tests__/json-output.test.ts +185 -2
  13. package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +150 -0
  14. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +298 -0
  15. package/src/components/editor/renderers/slides-layout/compute-slide-cells.ts +50 -0
  16. package/src/components/editor/renderers/slides-layout/plugin.tsx +54 -9
  17. package/src/components/editor/renderers/slides-layout/slides-layout.tsx +30 -12
  18. package/src/components/editor/renderers/slides-layout/types.ts +31 -3
  19. package/src/components/editor/renderers/types.ts +2 -0
  20. package/src/components/slides/__tests__/compose-slides.test.ts +433 -0
  21. package/src/components/slides/compose-slides.ts +337 -0
  22. package/src/components/slides/minimap.tsx +133 -12
  23. package/src/components/slides/reveal-component.tsx +337 -74
  24. package/src/components/slides/reveal-slides.css +33 -1
  25. package/src/components/slides/slide-form.tsx +347 -0
  26. package/src/components/ui/radio-group.tsx +5 -3
  27. package/src/core/cells/types.ts +2 -0
  28. package/src/core/islands/__tests__/bridge.test.ts +116 -5
  29. package/src/core/islands/bridge.ts +5 -1
  30. package/src/core/layout/layout.ts +6 -2
  31. package/src/core/static/__tests__/export-context.test.ts +122 -0
  32. package/src/core/static/__tests__/static-state.test.ts +80 -0
  33. package/src/core/static/export-context.ts +84 -0
  34. package/src/core/static/static-state.ts +44 -6
  35. package/src/plugins/core/RenderHTML.tsx +23 -2
  36. package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
  37. package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
  38. package/src/plugins/core/sanitize.ts +11 -5
  39. package/src/plugins/core/trusted-url.ts +32 -10
  40. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
  41. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
  42. package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
@@ -0,0 +1,122 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import type { ExtractAtomValue } from "jotai";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
7
+ import { userConfigAtom } from "@/core/config/config";
8
+ import { parseUserConfig } from "@/core/config/config-schema";
9
+ import { initialModeAtom } from "@/core/mode";
10
+ import { store } from "@/core/state/jotai";
11
+ import {
12
+ getMarimoExportContext,
13
+ hasTrustedExportContext,
14
+ hasTrustedNotebookContext,
15
+ } from "../export-context";
16
+
17
+ type ExportContextWindow = Window & {
18
+ __MARIMO_EXPORT_CONTEXT__?: {
19
+ trusted: boolean;
20
+ notebookCode?: string;
21
+ };
22
+ };
23
+
24
+ function setAutoInstantiate(value: boolean) {
25
+ const cleared = parseUserConfig({});
26
+ store.set(userConfigAtom, {
27
+ ...cleared,
28
+ runtime: { ...cleared.runtime, auto_instantiate: value },
29
+ });
30
+ }
31
+
32
+ describe("hasTrustedNotebookContext", () => {
33
+ let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
34
+ let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
35
+ let previousMode: ExtractAtomValue<typeof initialModeAtom>;
36
+ let w: ExportContextWindow;
37
+
38
+ beforeEach(() => {
39
+ w = window as ExportContextWindow;
40
+ previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
41
+ previousConfig = store.get(userConfigAtom);
42
+ previousMode = store.get(initialModeAtom);
43
+ store.set(hasRunAnyCellAtom, false);
44
+ setAutoInstantiate(false);
45
+ store.set(initialModeAtom, "edit");
46
+ delete w.__MARIMO_EXPORT_CONTEXT__;
47
+ });
48
+
49
+ afterEach(() => {
50
+ store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
51
+ store.set(userConfigAtom, previousConfig);
52
+ store.set(initialModeAtom, previousMode);
53
+ delete w.__MARIMO_EXPORT_CONTEXT__;
54
+ });
55
+
56
+ it("returns false in untrusted edit mode before interaction", () => {
57
+ expect(hasTrustedNotebookContext()).toBe(false);
58
+ });
59
+
60
+ it("returns true once the user has run a cell", () => {
61
+ store.set(hasRunAnyCellAtom, true);
62
+ expect(hasTrustedNotebookContext()).toBe(true);
63
+ });
64
+
65
+ it("returns true when a trusted export context is installed", () => {
66
+ w.__MARIMO_EXPORT_CONTEXT__ = { trusted: true };
67
+ expect(hasTrustedNotebookContext()).toBe(true);
68
+ });
69
+
70
+ it("returns true when auto_instantiate is enabled", () => {
71
+ setAutoInstantiate(true);
72
+ expect(hasTrustedNotebookContext()).toBe(true);
73
+ });
74
+
75
+ it("returns true in read mode", () => {
76
+ store.set(initialModeAtom, "read");
77
+ expect(hasTrustedNotebookContext()).toBe(true);
78
+ });
79
+
80
+ it("returns false if initialMode throws (config not yet applied)", () => {
81
+ store.set(initialModeAtom, undefined);
82
+ expect(hasTrustedNotebookContext()).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("hasTrustedExportContext / getMarimoExportContext shape validation", () => {
87
+ let w: ExportContextWindow;
88
+
89
+ beforeEach(() => {
90
+ w = window as ExportContextWindow;
91
+ delete w.__MARIMO_EXPORT_CONTEXT__;
92
+ });
93
+
94
+ afterEach(() => {
95
+ delete w.__MARIMO_EXPORT_CONTEXT__;
96
+ });
97
+
98
+ it("accepts a valid context", () => {
99
+ w.__MARIMO_EXPORT_CONTEXT__ = { trusted: true, notebookCode: "x = 1" };
100
+ expect(hasTrustedExportContext()).toBe(true);
101
+ expect(getMarimoExportContext()).toEqual({
102
+ trusted: true,
103
+ notebookCode: "x = 1",
104
+ });
105
+ });
106
+
107
+ it("rejects a context where `trusted` is not exactly true", () => {
108
+ w.__MARIMO_EXPORT_CONTEXT__ = {
109
+ trusted: "yes" as unknown as true,
110
+ };
111
+ expect(hasTrustedExportContext()).toBe(false);
112
+ expect(getMarimoExportContext()).toBeUndefined();
113
+ });
114
+
115
+ it("rejects a context with non-string notebookCode", () => {
116
+ w.__MARIMO_EXPORT_CONTEXT__ = {
117
+ trusted: true,
118
+ notebookCode: 42 as unknown as string,
119
+ };
120
+ expect(getMarimoExportContext()).toBeUndefined();
121
+ });
122
+ });
@@ -0,0 +1,80 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import {
6
+ getStaticModelNotifications,
7
+ getStaticVirtualFiles,
8
+ isStaticNotebook,
9
+ } from "../static-state";
10
+
11
+ function setMarimoStatic(value: unknown): void {
12
+ (window as unknown as { __MARIMO_STATIC__?: unknown }).__MARIMO_STATIC__ =
13
+ value;
14
+ }
15
+
16
+ function clearMarimoStatic(): void {
17
+ delete (window as unknown as { __MARIMO_STATIC__?: unknown })
18
+ .__MARIMO_STATIC__;
19
+ }
20
+
21
+ describe("static-state shape validation", () => {
22
+ beforeEach(() => {
23
+ clearMarimoStatic();
24
+ });
25
+
26
+ afterEach(() => {
27
+ clearMarimoStatic();
28
+ });
29
+
30
+ it("treats an absent global as not-a-static-notebook", () => {
31
+ expect(isStaticNotebook()).toBe(false);
32
+ expect(getStaticModelNotifications()).toBeUndefined();
33
+ });
34
+
35
+ it("accepts a well-formed state", () => {
36
+ setMarimoStatic({
37
+ files: { "/@file/a.txt": "data:text/plain;base64,YQ==" },
38
+ modelNotifications: [],
39
+ });
40
+ expect(isStaticNotebook()).toBe(true);
41
+ expect(getStaticVirtualFiles()).toEqual({
42
+ "/@file/a.txt": "data:text/plain;base64,YQ==",
43
+ });
44
+ expect(getStaticModelNotifications()).toEqual([]);
45
+ });
46
+
47
+ it("rejects a malformed global (missing files)", () => {
48
+ setMarimoStatic({ modelNotifications: [] });
49
+ expect(isStaticNotebook()).toBe(false);
50
+ expect(getStaticModelNotifications()).toBeUndefined();
51
+ });
52
+
53
+ it("rejects a malformed global (non-array modelNotifications)", () => {
54
+ setMarimoStatic({ files: {}, modelNotifications: "oops" });
55
+ expect(isStaticNotebook()).toBe(false);
56
+ });
57
+
58
+ it("rejects a non-object global", () => {
59
+ setMarimoStatic("pwned");
60
+ expect(isStaticNotebook()).toBe(false);
61
+ });
62
+
63
+ it("rejects an array as the state", () => {
64
+ setMarimoStatic([]);
65
+ expect(isStaticNotebook()).toBe(false);
66
+ });
67
+
68
+ it("rejects files when it is an array", () => {
69
+ setMarimoStatic({ files: [], modelNotifications: [] });
70
+ expect(isStaticNotebook()).toBe(false);
71
+ });
72
+
73
+ it("rejects files that contain non-string values", () => {
74
+ setMarimoStatic({
75
+ files: { "/@file/a.txt": 42 },
76
+ modelNotifications: [],
77
+ });
78
+ expect(isStaticNotebook()).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,84 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
4
+ import { autoInstantiateAtom } from "@/core/config/config";
5
+ import { getInitialAppMode } from "@/core/mode";
6
+ import { store } from "@/core/state/jotai";
7
+
8
+ export interface MarimoExportContext {
9
+ trusted: true;
10
+ notebookCode?: string;
11
+ }
12
+
13
+ declare global {
14
+ interface Window {
15
+ __MARIMO_EXPORT_CONTEXT__?: Readonly<MarimoExportContext>;
16
+ }
17
+ }
18
+
19
+ function isMarimoExportContext(
20
+ value: unknown,
21
+ ): value is Readonly<MarimoExportContext> {
22
+ if (typeof value !== "object" || value === null) {
23
+ return false;
24
+ }
25
+
26
+ const candidate = value as MarimoExportContext;
27
+ if (candidate.trusted !== true) {
28
+ return false;
29
+ }
30
+ if (
31
+ candidate.notebookCode !== undefined &&
32
+ typeof candidate.notebookCode !== "string"
33
+ ) {
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ export function getMarimoExportContext():
40
+ | Readonly<MarimoExportContext>
41
+ | undefined {
42
+ const context = window?.__MARIMO_EXPORT_CONTEXT__;
43
+ return isMarimoExportContext(context) ? context : undefined;
44
+ }
45
+
46
+ export function hasTrustedExportContext(): boolean {
47
+ return getMarimoExportContext()?.trusted === true;
48
+ }
49
+
50
+ /**
51
+ * True when the current page is a context where notebook-authored script
52
+ * execution is expected, and therefore the user has consented (explicitly or
53
+ * by the nature of the page) to running arbitrary notebook content:
54
+ *
55
+ * - the user has run at least one cell, OR
56
+ * - a first-party exported notebook page installed a trusted export context
57
+ * (islands / static exports / Quarto islands), OR
58
+ * - `auto_instantiate` is enabled (the notebook runs on page load by user
59
+ * configuration), OR
60
+ * - the page was loaded in `read` / app mode (served by marimo as an app).
61
+ *
62
+ * Edit mode before any user interaction is intentionally NOT trusted — that
63
+ * is the only surface where we must prevent notebook-authored content from
64
+ * loading scripts or bypassing HTML sanitization.
65
+ */
66
+ export function hasTrustedNotebookContext(): boolean {
67
+ if (store.get(hasRunAnyCellAtom)) {
68
+ return true;
69
+ }
70
+ if (hasTrustedExportContext()) {
71
+ return true;
72
+ }
73
+ if (store.get(autoInstantiateAtom)) {
74
+ return true;
75
+ }
76
+ try {
77
+ if (getInitialAppMode() === "read") {
78
+ return true;
79
+ }
80
+ } catch {
81
+ // getInitialAppMode throws before mount config is applied; treat as untrusted.
82
+ }
83
+ return false;
84
+ }
@@ -5,20 +5,58 @@ import type { MarimoStaticState, StaticVirtualFiles } from "./types";
5
5
 
6
6
  declare global {
7
7
  interface Window {
8
- __MARIMO_STATIC__?: MarimoStaticState;
8
+ __MARIMO_STATIC__?: Readonly<MarimoStaticState>;
9
9
  }
10
10
  }
11
11
 
12
+ function isStringToStringRecord(
13
+ value: unknown,
14
+ ): value is Record<string, string> {
15
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
16
+ return false;
17
+ }
18
+ for (const entry of Object.values(value)) {
19
+ if (typeof entry !== "string") {
20
+ return false;
21
+ }
22
+ }
23
+ return true;
24
+ }
25
+
26
+ function isMarimoStaticState(
27
+ value: unknown,
28
+ ): value is Readonly<MarimoStaticState> {
29
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
30
+ return false;
31
+ }
32
+ const candidate = value as MarimoStaticState;
33
+ if (!isStringToStringRecord(candidate.files)) {
34
+ return false;
35
+ }
36
+ if (
37
+ candidate.modelNotifications !== undefined &&
38
+ !Array.isArray(candidate.modelNotifications)
39
+ ) {
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ function getMarimoStaticState(): Readonly<MarimoStaticState> | undefined {
46
+ const state = window?.__MARIMO_STATIC__;
47
+ return isMarimoStaticState(state) ? state : undefined;
48
+ }
49
+
12
50
  export function isStaticNotebook(): boolean {
13
- return window?.__MARIMO_STATIC__ !== undefined;
51
+ return getMarimoStaticState() !== undefined;
14
52
  }
15
53
 
16
54
  export function getStaticVirtualFiles(): StaticVirtualFiles {
17
- invariant(window.__MARIMO_STATIC__ !== undefined, "Not a static notebook");
18
-
19
- return window.__MARIMO_STATIC__.files;
55
+ const state = getMarimoStaticState();
56
+ invariant(state !== undefined, "Not a static notebook");
57
+ return state.files;
20
58
  }
21
59
 
22
60
  export function getStaticModelNotifications(): ModelLifecycle[] | undefined {
23
- return window?.__MARIMO_STATIC__?.modelNotifications;
61
+ return getMarimoStaticState()?.modelNotifications;
24
62
  }
@@ -16,6 +16,8 @@ import { CopyClipboardIcon } from "@/components/icons/copy-icon";
16
16
  import { QueryParamPreservingLink } from "@/components/ui/query-param-preserving-link";
17
17
  import { Tooltip } from "@/components/ui/tooltip";
18
18
  import { DocHoverTarget } from "@/core/documentation/DocHoverTarget";
19
+ import { hasTrustedNotebookContext } from "@/core/static/export-context";
20
+ import { Logger } from "@/utils/Logger";
19
21
  import { sanitizeHtml, useSanitizeHtml } from "./sanitize";
20
22
 
21
23
  type ReplacementFn = NonNullable<HTMLReactParserOptions["replace"]>;
@@ -98,8 +100,27 @@ const replaceSrcScripts = (domNode: DOMNode): JSX.Element | undefined => {
98
100
  if (!src) {
99
101
  return;
100
102
  }
101
- // Check if script already exists
102
- if (!document.querySelector(`script[src="${src}"]`)) {
103
+ // Only append notebook-authored scripts when the page is a trusted
104
+ // context (the user has run a cell, the page is a trusted export, or
105
+ // we're running in read/app mode). In untrusted edit mode before any
106
+ // user interaction, drop the script and log a warning. Outer
107
+ // sanitization will normally strip <script> tags already; this is
108
+ // defense-in-depth for flows that reparse children with
109
+ // alwaysSanitizeHtml: false (see registerReactComponent.getChildren).
110
+ if (!hasTrustedNotebookContext()) {
111
+ Logger.warn(
112
+ `[RenderHTML] refusing <script src> in untrusted context: ${src}`,
113
+ );
114
+ // oxlint-disable-next-line react/jsx-no-useless-fragment
115
+ return <></>;
116
+ }
117
+ // Check if script already exists. Avoid building a CSS selector from
118
+ // notebook-provided input, which can throw for valid URLs containing
119
+ // selector-significant characters (e.g. IPv6 hosts with `[`/`]`).
120
+ const scriptExists = [...document.querySelectorAll("script[src]")].some(
121
+ (existingScript) => existingScript.getAttribute("src") === src,
122
+ );
123
+ if (!scriptExists) {
103
124
  const script = document.createElement("script");
104
125
  script.src = src;
105
126
  document.head.append(script);
@@ -1,5 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { describe, expect, test } from "vitest";
2
+ import type { ExtractAtomValue } from "jotai";
3
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
4
+ import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
5
+ import { userConfigAtom } from "@/core/config/config";
6
+ import { parseUserConfig } from "@/core/config/config-schema";
7
+ import { initialModeAtom } from "@/core/mode";
8
+ import { store } from "@/core/state/jotai";
3
9
  import { visibleForTesting } from "../RenderHTML";
4
10
 
5
11
  const { parseHtml } = visibleForTesting;
@@ -197,6 +203,85 @@ describe("parseHtml", () => {
197
203
  });
198
204
  });
199
205
 
206
+ describe("replaceSrcScripts trust gate", () => {
207
+ let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
208
+ let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
209
+ let previousMode: ExtractAtomValue<typeof initialModeAtom>;
210
+ const windowWithExport = window as Window & {
211
+ __MARIMO_EXPORT_CONTEXT__?: unknown;
212
+ };
213
+
214
+ function clearTrustSignals() {
215
+ const cleared = parseUserConfig({});
216
+ store.set(hasRunAnyCellAtom, false);
217
+ store.set(userConfigAtom, {
218
+ ...cleared,
219
+ runtime: { ...cleared.runtime, auto_instantiate: false },
220
+ });
221
+ store.set(initialModeAtom, "edit");
222
+ delete windowWithExport.__MARIMO_EXPORT_CONTEXT__;
223
+ }
224
+
225
+ beforeEach(() => {
226
+ previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
227
+ previousConfig = store.get(userConfigAtom);
228
+ previousMode = store.get(initialModeAtom);
229
+ clearTrustSignals();
230
+ for (const s of document.head.querySelectorAll(
231
+ 'script[src^="https://cdn.example.com/"]',
232
+ )) {
233
+ s.remove();
234
+ }
235
+ });
236
+
237
+ afterEach(() => {
238
+ store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
239
+ store.set(userConfigAtom, previousConfig);
240
+ store.set(initialModeAtom, previousMode);
241
+ delete windowWithExport.__MARIMO_EXPORT_CONTEXT__;
242
+ for (const s of document.head.querySelectorAll(
243
+ 'script[src^="https://cdn.example.com/"]',
244
+ )) {
245
+ s.remove();
246
+ }
247
+ });
248
+
249
+ test("drops <script src> in untrusted edit mode", () => {
250
+ parseHtml({
251
+ html: '<script src="https://cdn.example.com/unrun.js"></script>',
252
+ });
253
+ expect(
254
+ document.head.querySelector(
255
+ 'script[src="https://cdn.example.com/unrun.js"]',
256
+ ),
257
+ ).toBeNull();
258
+ });
259
+
260
+ test("loads <script src> once a cell has been run", () => {
261
+ store.set(hasRunAnyCellAtom, true);
262
+ parseHtml({
263
+ html: '<script src="https://cdn.example.com/ok.js"></script>',
264
+ });
265
+ expect(
266
+ document.head.querySelector(
267
+ 'script[src="https://cdn.example.com/ok.js"]',
268
+ ),
269
+ ).not.toBeNull();
270
+ });
271
+
272
+ test("loads <script src> when a trusted export context is installed", () => {
273
+ windowWithExport.__MARIMO_EXPORT_CONTEXT__ = { trusted: true };
274
+ parseHtml({
275
+ html: '<script src="https://cdn.example.com/export.js"></script>',
276
+ });
277
+ expect(
278
+ document.head.querySelector(
279
+ 'script[src="https://cdn.example.com/export.js"]',
280
+ ),
281
+ ).not.toBeNull();
282
+ });
283
+ });
284
+
200
285
  describe("wrapTooltipTargets", () => {
201
286
  test("data-tooltip wraps element in Tooltip component", () => {
202
287
  const html = '<span data-tooltip="Hello world">Hover me</span>';
@@ -1,10 +1,66 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
  import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
4
+ import { userConfigAtom } from "@/core/config/config";
5
+ import { parseUserConfig } from "@/core/config/config-schema";
6
+ import { initialModeAtom } from "@/core/mode";
4
7
  import { store } from "@/core/state/jotai";
5
8
  import { isTrustedVirtualFileUrl } from "../trusted-url";
6
9
 
10
+ type ExportContextWindow = Window & {
11
+ __MARIMO_EXPORT_CONTEXT__?: {
12
+ trusted: true;
13
+ notebookCode?: string;
14
+ };
15
+ __MARIMO_STATIC__?: unknown;
16
+ };
17
+
18
+ function snapshotTrustState() {
19
+ return {
20
+ hasRunAnyCell: store.get(hasRunAnyCellAtom),
21
+ userConfig: store.get(userConfigAtom),
22
+ initialMode: store.get(initialModeAtom),
23
+ };
24
+ }
25
+
26
+ function restoreTrustState(snapshot: ReturnType<typeof snapshotTrustState>) {
27
+ store.set(hasRunAnyCellAtom, snapshot.hasRunAnyCell);
28
+ store.set(userConfigAtom, snapshot.userConfig);
29
+ store.set(initialModeAtom, snapshot.initialMode);
30
+ }
31
+
32
+ function setAutoInstantiate(value: boolean) {
33
+ const cleared = parseUserConfig({});
34
+ store.set(userConfigAtom, {
35
+ ...cleared,
36
+ runtime: { ...cleared.runtime, auto_instantiate: value },
37
+ });
38
+ }
39
+
40
+ function clearTrustSignals() {
41
+ store.set(hasRunAnyCellAtom, false);
42
+ setAutoInstantiate(false);
43
+ store.set(initialModeAtom, "edit");
44
+ }
45
+
7
46
  describe("isTrustedVirtualFileUrl", () => {
47
+ let windowWithExportContext: ExportContextWindow;
48
+ let trustStateSnapshot: ReturnType<typeof snapshotTrustState>;
49
+
50
+ beforeEach(() => {
51
+ windowWithExportContext = window as ExportContextWindow;
52
+ trustStateSnapshot = snapshotTrustState();
53
+ clearTrustSignals();
54
+ delete windowWithExportContext.__MARIMO_EXPORT_CONTEXT__;
55
+ delete windowWithExportContext.__MARIMO_STATIC__;
56
+ });
57
+
58
+ afterEach(() => {
59
+ restoreTrustState(trustStateSnapshot);
60
+ delete windowWithExportContext.__MARIMO_EXPORT_CONTEXT__;
61
+ delete windowWithExportContext.__MARIMO_STATIC__;
62
+ });
63
+
8
64
  it.each([
9
65
  "./@file/123-mpl.js",
10
66
  "./@file/456-mpl.css",
@@ -48,40 +104,96 @@ describe("isTrustedVirtualFileUrl", () => {
48
104
  expect(isTrustedVirtualFileUrl({})).toBe(false);
49
105
  });
50
106
 
51
- describe("data URL exemption", () => {
52
- let previousHasRunAnyCell: boolean;
107
+ /**
108
+ * Data URLs are the WASM / Pyodide fallback shape (see
109
+ * `virtual_file.py`: when `virtual_files_supported=False`, files are
110
+ * emitted directly as base64 data URLs). The tests below cover each
111
+ * supported and unsupported trust signal.
112
+ */
113
+ describe("data URL acceptance", () => {
114
+ const SAFE_DATA_URLS = [
115
+ "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
116
+ "data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
117
+ "data:text/css;base64,Ym9keXt9",
118
+ ];
53
119
 
54
- beforeEach(() => {
55
- previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
56
- store.set(hasRunAnyCellAtom, true);
120
+ it.each(SAFE_DATA_URLS)(
121
+ "accepts %s once the user has run a cell",
122
+ (url) => {
123
+ store.set(hasRunAnyCellAtom, true);
124
+ expect(isTrustedVirtualFileUrl(url)).toBe(true);
125
+ },
126
+ );
127
+
128
+ it("accepts safe data URL when trusted export context is present", () => {
129
+ windowWithExportContext.__MARIMO_EXPORT_CONTEXT__ = {
130
+ trusted: true,
131
+ notebookCode: "import marimo\napp = marimo.App()",
132
+ };
133
+ expect(
134
+ isTrustedVirtualFileUrl(
135
+ "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
136
+ ),
137
+ ).toBe(true);
138
+ });
139
+
140
+ it("rejects safe data URL when only read mode is present", () => {
141
+ store.set(initialModeAtom, "read");
142
+ expect(
143
+ isTrustedVirtualFileUrl(
144
+ "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
145
+ ),
146
+ ).toBe(false);
57
147
  });
58
148
 
59
- afterEach(() => {
60
- store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
149
+ it("rejects safe data URL when only auto_instantiate is enabled", () => {
150
+ setAutoInstantiate(true);
151
+ expect(
152
+ isTrustedVirtualFileUrl(
153
+ "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
154
+ ),
155
+ ).toBe(false);
61
156
  });
62
157
 
63
- it.each([
64
- "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
65
- "data:application/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
66
- "data:text/css;base64,Ym9keXt9",
67
- ])("accepts safe data URL %s after a cell has run", (url) => {
68
- expect(isTrustedVirtualFileUrl(url)).toBe(true);
158
+ it("rejects safe data URL when only a marimo-code tag is present", () => {
159
+ const tag = document.createElement("marimo-code");
160
+ tag.textContent = encodeURIComponent("import marimo\napp = marimo.App()");
161
+ document.body.appendChild(tag);
162
+ try {
163
+ expect(
164
+ isTrustedVirtualFileUrl(
165
+ "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
166
+ ),
167
+ ).toBe(false);
168
+ } finally {
169
+ tag.remove();
170
+ }
171
+ });
172
+
173
+ it("rejects safe data URL when only __MARIMO_STATIC__ is present", () => {
174
+ windowWithExportContext.__MARIMO_STATIC__ = { files: {} };
175
+ expect(
176
+ isTrustedVirtualFileUrl(
177
+ "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",
178
+ ),
179
+ ).toBe(false);
69
180
  });
70
181
 
71
182
  it.each([
72
- // Non-base64 data URLs are refused
183
+ // Non-base64 data URLs are refused because the unencoded payload
184
+ // broadens the parsing/loading surface for attacker-controlled content.
73
185
  "data:text/javascript,alert(1)",
74
186
  "data:text/javascript;charset=utf-8,alert(1)",
75
- // HTML / SVG / arbitrary types are refused
187
+ // HTML / SVG / arbitrary types are refused even when trusted.
76
188
  "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
77
189
  "data:image/svg+xml;base64,PHN2Zy8+",
78
190
  "data:application/octet-stream;base64,AAA=",
79
- ])("still rejects unsafe data URL %s", (url) => {
191
+ ])("still rejects unsafe data URL %s in trusted context", (url) => {
192
+ store.set(hasRunAnyCellAtom, true);
80
193
  expect(isTrustedVirtualFileUrl(url)).toBe(false);
81
194
  });
82
195
 
83
- it("rejects data URL when no cell has been run", () => {
84
- store.set(hasRunAnyCellAtom, false);
196
+ it("rejects data URLs when no trust signal is set", () => {
85
197
  expect(
86
198
  isTrustedVirtualFileUrl(
87
199
  "data:text/javascript;base64,ZXhwb3J0IGRlZmF1bHQge30=",