@marimo-team/islands 0.23.3-dev4 → 0.23.3-dev41
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.
- package/dist/{chat-ui-CTt4WX0V.js → chat-ui-DEd_Ndal.js} +82 -82
- package/dist/{html-to-image-BdsDysfl.js → html-to-image-DBosi5GK.js} +2243 -2217
- package/dist/main.js +1104 -1099
- package/dist/{process-output-COL2Pf5I.js → process-output-k-4WHpxz.js} +1 -1
- package/dist/{reveal-component-Cd5Y35Ny.js → reveal-component-agH2Be6_.js} +2 -2
- package/dist/{slide-BEerfanN.js → slide-CoAyRjHI.js} +693 -574
- package/package.json +2 -2
- package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +84 -2
- package/src/components/editor/file-tree/file-explorer.tsx +142 -203
- package/src/components/editor/file-tree/file-name-input.tsx +41 -0
- package/src/components/editor/file-tree/file-operations.tsx +266 -0
- package/src/components/editor/file-tree/renderers.tsx +1 -1
- package/src/components/editor/file-tree/requesting-tree.tsx +68 -49
- package/src/components/editor/output/JsonOutput.tsx +157 -4
- package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
- package/src/components/editor/output/__tests__/json-output.test.ts +147 -2
- package/src/components/home/state.ts +13 -1
- package/src/components/pages/home-page.tsx +116 -10
- package/src/core/islands/__tests__/bridge.test.ts +116 -5
- package/src/core/islands/bridge.ts +5 -1
- package/src/core/network/requests-network.ts +0 -3
- package/src/core/static/__tests__/export-context.test.ts +122 -0
- package/src/core/static/__tests__/static-state.test.ts +80 -0
- package/src/core/static/export-context.ts +84 -0
- package/src/core/static/static-state.ts +44 -6
- package/src/plugins/core/RenderHTML.tsx +23 -2
- package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
- package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
- package/src/plugins/core/sanitize.ts +11 -5
- package/src/plugins/core/trusted-url.ts +32 -10
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
- package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
- package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
- package/src/utils/__tests__/path.test.ts +20 -0
- package/src/utils/pathUtils.test.ts +141 -1
- package/src/utils/pathUtils.ts +46 -0
- package/src/utils/paths.ts +9 -1
|
@@ -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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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=",
|
|
@@ -3,25 +3,31 @@ import { atom, useAtomValue } from "jotai";
|
|
|
3
3
|
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
4
4
|
import { autoInstantiateAtom } from "@/core/config/config";
|
|
5
5
|
import { getInitialAppMode } from "@/core/mode";
|
|
6
|
+
import { hasTrustedExportContext } from "@/core/static/export-context";
|
|
6
7
|
|
|
7
8
|
// Re-export so existing consumers don't break.
|
|
8
9
|
export { sanitizeHtml } from "./sanitize-html";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
|
-
* Whether to sanitize the html.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Whether to sanitize the html. Trust signals match
|
|
13
|
+
* `hasTrustedNotebookContext` in `@/core/static/export-context`.
|
|
14
|
+
* Kept as a jotai atom so consumers re-render when `hasRunAnyCellAtom`
|
|
15
|
+
* flips after the user runs a cell.
|
|
14
16
|
*/
|
|
15
17
|
const sanitizeHtmlAtom = atom<boolean>((get) => {
|
|
16
18
|
const hasRunAnyCell = get(hasRunAnyCellAtom);
|
|
17
19
|
const autoInstantiate = get(autoInstantiateAtom);
|
|
18
20
|
|
|
19
|
-
// If a user has specifically run at least one cell or auto_instantiate is enabled, we don't need to sanitize.
|
|
20
|
-
// HTML needs to be rich to allow for interactive widgets and other dynamic content.
|
|
21
21
|
if (hasRunAnyCell || autoInstantiate) {
|
|
22
22
|
return false;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Trusted export context is installed once at page load by a first-party
|
|
26
|
+
// script (frozen + non-configurable), so a direct read is safe and stable.
|
|
27
|
+
if (hasTrustedExportContext()) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
let isInAppMode = true;
|
|
26
32
|
try {
|
|
27
33
|
isInAppMode = getInitialAppMode() === "read";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { hasRunAnyCellAtom } from "@/components/editor/cell/useRunCells";
|
|
4
|
+
import { hasTrustedExportContext } from "@/core/static/export-context";
|
|
4
5
|
import { store } from "@/core/state/jotai";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -15,13 +16,13 @@ import { store } from "@/core/state/jotai";
|
|
|
15
16
|
* attacker-controlled JavaScript at same origin, since the HTML sanitizer
|
|
16
17
|
* lets arbitrary marimo custom elements and attributes through.
|
|
17
18
|
*
|
|
18
|
-
* Some runtimes (WASM, VS Code
|
|
19
|
-
*
|
|
19
|
+
* Some runtimes (WASM, VS Code, and trusted exported notebook contexts such as
|
|
20
|
+
* Quarto islands) have no backend to serve virtual files, so `VirtualFile`
|
|
21
|
+
* falls back to inline base64 data URLs (see `virtual_file.py`).
|
|
20
22
|
* We accept those only once the user has explicitly run a cell in the current
|
|
21
|
-
* notebook
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* new attack surface.
|
|
23
|
+
* notebook, or when a first-party export script has installed a trusted
|
|
24
|
+
* notebook export context. Both cases already imply trust in notebook-authored
|
|
25
|
+
* code, so loading the matching data URL is not a new attack surface.
|
|
25
26
|
*/
|
|
26
27
|
export function isTrustedVirtualFileUrl(url: unknown): url is string {
|
|
27
28
|
if (typeof url !== "string" || url.length === 0) {
|
|
@@ -30,16 +31,37 @@ export function isTrustedVirtualFileUrl(url: unknown): url is string {
|
|
|
30
31
|
if (/^(\.?\/)?@file\/[^?#]+$/.test(url)) {
|
|
31
32
|
return true;
|
|
32
33
|
}
|
|
33
|
-
if (isSafeDataUrl(url)
|
|
34
|
+
if (isSafeDataUrl(url)) {
|
|
34
35
|
return true;
|
|
35
36
|
}
|
|
36
37
|
return false;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Intentionally narrower than `hasTrustedNotebookContext` in
|
|
42
|
+
* `@/core/static/export-context`: `auto_instantiate` and `read` mode are
|
|
43
|
+
* deliberately excluded here. Both can be triggered by DOM-observable page
|
|
44
|
+
* shape, and accepting inline base64 `data:` JS/CSS payloads on their
|
|
45
|
+
* strength would let a hostile notebook page smuggle attacker-controlled
|
|
46
|
+
* script into the same origin. Keep this gate tied only to "user actively
|
|
47
|
+
* ran a cell" or "first-party exporter installed a trusted runtime marker".
|
|
48
|
+
*/
|
|
49
|
+
function hasNotebookTrustedDataUrlContext(): boolean {
|
|
50
|
+
return store.get(hasRunAnyCellAtom) || hasTrustedExportContext();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Safe data URL formats: JS/CSS inlined as base64. Non-base64 data URLs and
|
|
55
|
+
* other MIME types (HTML, SVG, octet-stream, etc.) are refused because they
|
|
56
|
+
* broaden the surface for attacker-controlled inline content.
|
|
57
|
+
*/
|
|
39
58
|
function isSafeDataUrl(url: string): boolean {
|
|
40
|
-
|
|
59
|
+
const isSafeKind =
|
|
41
60
|
url.startsWith("data:text/javascript;base64,") ||
|
|
42
61
|
url.startsWith("data:application/javascript;base64,") ||
|
|
43
|
-
url.startsWith("data:text/css;base64,")
|
|
44
|
-
)
|
|
62
|
+
url.startsWith("data:text/css;base64,");
|
|
63
|
+
if (!isSafeKind) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return hasNotebookTrustedDataUrlContext();
|
|
45
67
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
2
|
+
import type { ExtractAtomValue } from "jotai";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } 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 { Model } from "../model";
|
|
4
10
|
import type { ModelState, WidgetModelId } from "../types";
|
|
5
11
|
import { visibleForTesting } from "../widget-binding";
|
|
@@ -18,9 +24,31 @@ function createMockComm() {
|
|
|
18
24
|
|
|
19
25
|
describe("WidgetDefRegistry", () => {
|
|
20
26
|
let registry: InstanceType<typeof WidgetDefRegistry>;
|
|
27
|
+
let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
|
|
28
|
+
let previousMode: ExtractAtomValue<typeof initialModeAtom>;
|
|
29
|
+
let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
|
|
21
30
|
|
|
22
31
|
beforeEach(() => {
|
|
23
32
|
registry = new WidgetDefRegistry();
|
|
33
|
+
// Force "no notebook trust" so the `data:` rejection test below
|
|
34
|
+
// exercises the untrusted branch. The positive trust path is covered
|
|
35
|
+
// centrally in trusted-url.test.ts.
|
|
36
|
+
previousConfig = store.get(userConfigAtom);
|
|
37
|
+
previousMode = store.get(initialModeAtom);
|
|
38
|
+
previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
|
|
39
|
+
store.set(hasRunAnyCellAtom, false);
|
|
40
|
+
const cleared = parseUserConfig({});
|
|
41
|
+
store.set(userConfigAtom, {
|
|
42
|
+
...cleared,
|
|
43
|
+
runtime: { ...cleared.runtime, auto_instantiate: false },
|
|
44
|
+
});
|
|
45
|
+
store.set(initialModeAtom, "edit");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
store.set(userConfigAtom, previousConfig);
|
|
50
|
+
store.set(initialModeAtom, previousMode);
|
|
51
|
+
store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
|
|
24
52
|
});
|
|
25
53
|
|
|
26
54
|
it("should cache modules by jsHash and return same promise", () => {
|
|
@@ -1,12 +1,41 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type { ExtractAtomValue } from "jotai";
|
|
2
3
|
import { afterEach, beforeEach, describe, expect, it, vi } 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 { Logger } from "@/utils/Logger";
|
|
4
10
|
import { visibleForTesting } from "../MplInteractivePlugin";
|
|
5
11
|
|
|
6
12
|
const { ensureMplJs, injectCss, resetMplJsLoading } = visibleForTesting;
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Clear every "notebook trust" signal `isTrustedVirtualFileUrl` consults so
|
|
16
|
+
* the rejection cases below test the actually-untrusted branch. Positive
|
|
17
|
+
* export-context trust is covered centrally in trusted-url.test.ts.
|
|
18
|
+
*/
|
|
19
|
+
function clearTrustSignals() {
|
|
20
|
+
store.set(hasRunAnyCellAtom, false);
|
|
21
|
+
const cleared = parseUserConfig({});
|
|
22
|
+
store.set(userConfigAtom, {
|
|
23
|
+
...cleared,
|
|
24
|
+
runtime: { ...cleared.runtime, auto_instantiate: false },
|
|
25
|
+
});
|
|
26
|
+
store.set(initialModeAtom, "edit");
|
|
27
|
+
}
|
|
28
|
+
|
|
8
29
|
describe("MplInteractivePlugin URL validation", () => {
|
|
30
|
+
let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
|
|
31
|
+
let previousMode: ExtractAtomValue<typeof initialModeAtom>;
|
|
32
|
+
let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
|
|
33
|
+
|
|
9
34
|
beforeEach(() => {
|
|
35
|
+
previousConfig = store.get(userConfigAtom);
|
|
36
|
+
previousMode = store.get(initialModeAtom);
|
|
37
|
+
previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
|
|
38
|
+
clearTrustSignals();
|
|
10
39
|
// Reset module-level script-loading state and any stubs.
|
|
11
40
|
delete (window as { mpl?: unknown }).mpl;
|
|
12
41
|
resetMplJsLoading();
|
|
@@ -20,6 +49,9 @@ describe("MplInteractivePlugin URL validation", () => {
|
|
|
20
49
|
|
|
21
50
|
afterEach(() => {
|
|
22
51
|
vi.restoreAllMocks();
|
|
52
|
+
store.set(userConfigAtom, previousConfig);
|
|
53
|
+
store.set(initialModeAtom, previousMode);
|
|
54
|
+
store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
|
|
23
55
|
});
|
|
24
56
|
|
|
25
57
|
describe("ensureMplJs", () => {
|
|
@@ -35,6 +67,8 @@ describe("MplInteractivePlugin URL validation", () => {
|
|
|
35
67
|
"https://evil.example.com/x.js",
|
|
36
68
|
"//evil.example.com/x.js",
|
|
37
69
|
"javascript:alert(1)",
|
|
70
|
+
// Data URL is rejected only in an untrusted context. WASM/autoInstantiate
|
|
71
|
+
// intentionally accepts it — covered by trusted-url.test.ts.
|
|
38
72
|
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
39
73
|
"./@file/x.js?redirect=http://evil.com",
|
|
40
74
|
])("rejects %s", async (url) => {
|
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type { ExtractAtomValue } from "jotai";
|
|
2
3
|
import { afterEach, beforeEach, describe, expect, it, vi } 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 { Logger } from "@/utils/Logger";
|
|
4
10
|
import { loadPanelExtension } from "../PanelPlugin";
|
|
5
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Force the "no notebook trust" branch so the `data:` URL rejection below
|
|
14
|
+
* actually exercises the untrusted path. Positive export-context trust is
|
|
15
|
+
* covered centrally in trusted-url.test.ts.
|
|
16
|
+
*/
|
|
17
|
+
function clearTrustSignals() {
|
|
18
|
+
store.set(hasRunAnyCellAtom, false);
|
|
19
|
+
const cleared = parseUserConfig({});
|
|
20
|
+
store.set(userConfigAtom, {
|
|
21
|
+
...cleared,
|
|
22
|
+
runtime: { ...cleared.runtime, auto_instantiate: false },
|
|
23
|
+
});
|
|
24
|
+
store.set(initialModeAtom, "edit");
|
|
25
|
+
}
|
|
26
|
+
|
|
6
27
|
describe("loadPanelExtension", () => {
|
|
28
|
+
let previousConfig: ExtractAtomValue<typeof userConfigAtom>;
|
|
29
|
+
let previousMode: ExtractAtomValue<typeof initialModeAtom>;
|
|
30
|
+
let previousHasRunAnyCell: ExtractAtomValue<typeof hasRunAnyCellAtom>;
|
|
31
|
+
|
|
7
32
|
beforeEach(() => {
|
|
33
|
+
previousConfig = store.get(userConfigAtom);
|
|
34
|
+
previousMode = store.get(initialModeAtom);
|
|
35
|
+
previousHasRunAnyCell = store.get(hasRunAnyCellAtom);
|
|
36
|
+
clearTrustSignals();
|
|
8
37
|
for (const el of document.head.querySelectorAll("script")) {
|
|
9
38
|
el.remove();
|
|
10
39
|
}
|
|
@@ -12,6 +41,9 @@ describe("loadPanelExtension", () => {
|
|
|
12
41
|
|
|
13
42
|
afterEach(() => {
|
|
14
43
|
vi.restoreAllMocks();
|
|
44
|
+
store.set(userConfigAtom, previousConfig);
|
|
45
|
+
store.set(initialModeAtom, previousMode);
|
|
46
|
+
store.set(hasRunAnyCellAtom, previousHasRunAnyCell);
|
|
15
47
|
});
|
|
16
48
|
|
|
17
49
|
it("does nothing and returns false for null URL", () => {
|
|
@@ -35,8 +67,9 @@ describe("loadPanelExtension", () => {
|
|
|
35
67
|
it.each([
|
|
36
68
|
"https://evil.example.com/x.js",
|
|
37
69
|
"//evil.example.com/x.js",
|
|
38
|
-
//
|
|
39
|
-
//
|
|
70
|
+
// Data URL is rejected only in an untrusted context — the WASM fallback
|
|
71
|
+
// legitimately produces these, so trusted-url.test.ts covers the
|
|
72
|
+
// positive path when a notebook trust signal is set.
|
|
40
73
|
"data:text/javascript;base64,YWxlcnQoMSk=",
|
|
41
74
|
"javascript:alert(1)",
|
|
42
75
|
"./@file/x.js#http://evil.com",
|
|
@@ -9,6 +9,26 @@ describe("Paths", () => {
|
|
|
9
9
|
expect(Paths.isAbsolute("/user/docs/Letter.txt")).toBe(true);
|
|
10
10
|
expect(Paths.isAbsolute("C:\\user\\docs\\Letter.txt")).toBe(true);
|
|
11
11
|
expect(Paths.isAbsolute("user/docs/Letter.txt")).toBe(false);
|
|
12
|
+
|
|
13
|
+
// Any Windows drive letter, either separator, and any case
|
|
14
|
+
expect(Paths.isAbsolute("D:\\Users\\x\\a.py")).toBe(true);
|
|
15
|
+
expect(Paths.isAbsolute("z:/tmp/file")).toBe(true);
|
|
16
|
+
expect(Paths.isAbsolute("e:\\")).toBe(true);
|
|
17
|
+
|
|
18
|
+
// UNC / server paths
|
|
19
|
+
expect(Paths.isAbsolute("\\\\server\\share\\file")).toBe(true);
|
|
20
|
+
|
|
21
|
+
// URI schemes
|
|
22
|
+
expect(Paths.isAbsolute("s3://bucket/key")).toBe(true);
|
|
23
|
+
expect(Paths.isAbsolute("gs://bucket/key")).toBe(true);
|
|
24
|
+
expect(Paths.isAbsolute("file:///tmp/file")).toBe(true);
|
|
25
|
+
expect(Paths.isAbsolute("http://example.com/x")).toBe(true);
|
|
26
|
+
|
|
27
|
+
// Negative cases
|
|
28
|
+
expect(Paths.isAbsolute("C:file")).toBe(false); // drive without separator
|
|
29
|
+
expect(Paths.isAbsolute("notebook.py")).toBe(false);
|
|
30
|
+
expect(Paths.isAbsolute("./relative")).toBe(false);
|
|
31
|
+
expect(Paths.isAbsolute("")).toBe(false);
|
|
12
32
|
});
|
|
13
33
|
|
|
14
34
|
describe("dirname", () => {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
fileSplit,
|
|
5
|
+
getProtocolAndParentDirectories,
|
|
6
|
+
makeDuplicateName,
|
|
7
|
+
resolvePaths,
|
|
8
|
+
toAbsolutePath,
|
|
9
|
+
} from "./pathUtils";
|
|
4
10
|
|
|
5
11
|
describe("getProtocolAndParentDirectories", () => {
|
|
6
12
|
it("should extract protocol and list parent directories correctly", () => {
|
|
@@ -111,3 +117,137 @@ describe("fileSplit", () => {
|
|
|
111
117
|
]);
|
|
112
118
|
});
|
|
113
119
|
});
|
|
120
|
+
|
|
121
|
+
describe("makeDuplicateName", () => {
|
|
122
|
+
it("appends _copy before the extension", () => {
|
|
123
|
+
expect(makeDuplicateName("notebook.py")).toBe("notebook_copy.py");
|
|
124
|
+
expect(makeDuplicateName("foo.tar.gz")).toBe("foo.tar_copy.gz");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("appends _copy for extensionless names", () => {
|
|
128
|
+
expect(makeDuplicateName("README")).toBe("README_copy");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("toAbsolutePath", () => {
|
|
133
|
+
it("joins relative paths against the root", () => {
|
|
134
|
+
expect(toAbsolutePath("notebooks/a.py", "/workspaces/marimo")).toBe(
|
|
135
|
+
"/workspaces/marimo/notebooks/a.py",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns absolute POSIX paths unchanged", () => {
|
|
140
|
+
expect(toAbsolutePath("/abs/a.py", "/workspaces/marimo")).toBe("/abs/a.py");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("returns absolute Windows paths unchanged", () => {
|
|
144
|
+
expect(toAbsolutePath("C:\\Users\\x\\a.py", "C:\\Users\\marimo")).toBe(
|
|
145
|
+
"C:\\Users\\x\\a.py",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("joins relative paths using the Windows delimiter", () => {
|
|
150
|
+
expect(toAbsolutePath("a.py", "C:\\Users\\marimo")).toBe(
|
|
151
|
+
"C:\\Users\\marimo\\a.py",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("resolvePaths", () => {
|
|
157
|
+
it("resolves relative paths against the root", () => {
|
|
158
|
+
expect(
|
|
159
|
+
resolvePaths({
|
|
160
|
+
path: "notebooks/notebook.py",
|
|
161
|
+
name: "notebook_copy.py",
|
|
162
|
+
root: "/workspaces/marimo",
|
|
163
|
+
}),
|
|
164
|
+
).toEqual({
|
|
165
|
+
path: "/workspaces/marimo/notebooks/notebook.py",
|
|
166
|
+
newPath: "/workspaces/marimo/notebooks/notebook_copy.py",
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps absolute source paths untouched", () => {
|
|
171
|
+
expect(
|
|
172
|
+
resolvePaths({
|
|
173
|
+
path: "/abs/path/notebook.py",
|
|
174
|
+
name: "notebook_copy.py",
|
|
175
|
+
root: "/workspaces/marimo",
|
|
176
|
+
}),
|
|
177
|
+
).toEqual({
|
|
178
|
+
path: "/abs/path/notebook.py",
|
|
179
|
+
newPath: "/abs/path/notebook_copy.py",
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("handles Windows roots for relative paths", () => {
|
|
184
|
+
expect(
|
|
185
|
+
resolvePaths({
|
|
186
|
+
path: "notebook.py",
|
|
187
|
+
name: "notebook_copy.py",
|
|
188
|
+
root: "C:\\Users\\marimo",
|
|
189
|
+
}),
|
|
190
|
+
).toEqual({
|
|
191
|
+
path: "C:\\Users\\marimo\\notebook.py",
|
|
192
|
+
newPath: "C:\\Users\\marimo\\notebook_copy.py",
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("handles Windows absolute source paths", () => {
|
|
197
|
+
// The tricky case: `path` is an absolute Windows path with a drive letter,
|
|
198
|
+
// so it must be detected by `Paths.isAbsolute` AND the deliminator must be
|
|
199
|
+
// picked up from the root so the join uses backslashes.
|
|
200
|
+
expect(
|
|
201
|
+
resolvePaths({
|
|
202
|
+
path: "C:\\Users\\marimo\\folder\\notebook.py",
|
|
203
|
+
name: "notebook_copy.py",
|
|
204
|
+
root: "C:\\Users\\marimo",
|
|
205
|
+
}),
|
|
206
|
+
).toEqual({
|
|
207
|
+
path: "C:\\Users\\marimo\\folder\\notebook.py",
|
|
208
|
+
newPath: "C:\\Users\\marimo\\folder\\notebook_copy.py",
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("keeps the file in its current directory when renaming", () => {
|
|
213
|
+
expect(
|
|
214
|
+
resolvePaths({
|
|
215
|
+
path: "/root/a/b/file.py",
|
|
216
|
+
name: "renamed.py",
|
|
217
|
+
root: "/root",
|
|
218
|
+
}),
|
|
219
|
+
).toEqual({
|
|
220
|
+
path: "/root/a/b/file.py",
|
|
221
|
+
newPath: "/root/a/b/renamed.py",
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("handles an empty root with an absolute POSIX path", () => {
|
|
226
|
+
// Callers that already hold absolute paths pass `root: ""`. In that case
|
|
227
|
+
// the delimiter must be inferred from the path itself, not from `""` (which
|
|
228
|
+
// would otherwise default to Windows backslashes).
|
|
229
|
+
expect(
|
|
230
|
+
resolvePaths({
|
|
231
|
+
path: "/abs/path/file.py",
|
|
232
|
+
name: "renamed.py",
|
|
233
|
+
root: "",
|
|
234
|
+
}),
|
|
235
|
+
).toEqual({
|
|
236
|
+
path: "/abs/path/file.py",
|
|
237
|
+
newPath: "/abs/path/renamed.py",
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("handles an empty root with an absolute Windows path", () => {
|
|
242
|
+
expect(
|
|
243
|
+
resolvePaths({
|
|
244
|
+
path: "C:\\Users\\marimo\\file.py",
|
|
245
|
+
name: "renamed.py",
|
|
246
|
+
root: "",
|
|
247
|
+
}),
|
|
248
|
+
).toEqual({
|
|
249
|
+
path: "C:\\Users\\marimo\\file.py",
|
|
250
|
+
newPath: "C:\\Users\\marimo\\renamed.py",
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|