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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions e2e/helpers/electron-api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@ function installApiMock(opts: {
if (path in store.files) return store.files[path];
throw Object.assign(new Error(`File not found: ${path}`), { kind: "ENOENT" });
},
readFileBase64: async (path: string): Promise<string> => {
track("readFileBase64", [path]);
throw Object.assign(new Error(`File not found: ${path}`), { kind: "ENOENT" });
},
writeFile: async (path: string, content: string): Promise<void> => {
track("writeFile", [path, content]);
store.files[path] = content;
Expand Down
68 changes: 68 additions & 0 deletions electron/main/ipc/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const OTHER_WIN = 2;

const {
readFileImpl,
readFileBase64Impl,
readFileBoundedFromHandle,
writeFileImpl,
writeNewFileImpl,
Expand Down Expand Up @@ -108,6 +109,73 @@ describe("readFileImpl", () => {
});
});

describe("readFileBase64Impl (#314 data URI 埋め込み用)", () => {
it("reads a png as base64", async () => {
const path = join(workspaceDir, "hero.png");
// 8-byte PNG magic + minimal payload
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await writeFile(path, bytes);
const b64 = await readFileBase64Impl(TEST_WIN, path);
expect(b64).toBe(bytes.toString("base64"));
expect(Buffer.from(b64, "base64")).toEqual(bytes);
});

it("reads a jpeg as base64", async () => {
const path = join(workspaceDir, "photo.jpg");
const bytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
await writeFile(path, bytes);
const b64 = await readFileBase64Impl(TEST_WIN, path);
expect(Buffer.from(b64, "base64")).toEqual(bytes);
});

it("rejects non-image extensions (mp4)", async () => {
const path = join(workspaceDir, "video.mp4");
await writeFile(path, Buffer.from([0x00]));
await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/unsupported extension/);
});

it("rejects extensionless files", async () => {
const path = join(workspaceDir, "README");
await writeFile(path, Buffer.from([0x00]));
await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/unsupported extension/);
});

it("case-insensitive extension check (PNG)", async () => {
const path = join(workspaceDir, "A.PNG");
const bytes = Buffer.from([0x89, 0x50]);
await writeFile(path, bytes);
const b64 = await readFileBase64Impl(TEST_WIN, path);
expect(Buffer.from(b64, "base64")).toEqual(bytes);
});

it("rejects paths outside workspace (path-guard)", async () => {
const outside = join(tmpdir(), "scripta-outside.png");
await writeFile(outside, Buffer.from([0x00]));
try {
await expect(readFileBase64Impl(TEST_WIN, outside)).rejects.toThrow(/outside workspace/);
} finally {
await rm(outside, { force: true });
}
});

it("rejects paths not registered by this window (window-scoped)", async () => {
const path = join(workspaceDir, "hero.png");
await writeFile(path, Buffer.from([0x89, 0x50]));
await expect(readFileBase64Impl(OTHER_WIN, path)).rejects.toThrow(/outside workspace/);
});

it("rejects files exceeding MAX_READ_FILE_BYTES", async () => {
const path = join(workspaceDir, "huge.png");
await createSparseFile(path, MAX_READ_FILE_BYTES + 1);
await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/too large|FILE_TOO_LARGE/i);
});

it("throws ENOENT for missing file", async () => {
const path = join(workspaceDir, "missing.png");
await expect(readFileBase64Impl(TEST_WIN, path)).rejects.toThrow(/ENOENT/);
});
});

describe("readFileBoundedFromHandle (stat の信頼性に依存しない bounded read)", () => {
// stat 申告と実 read を独立に制御する fake FileHandle。
// stat.size と read の data を別個に指定できるので、外部書込(file watcher 経路)
Expand Down
40 changes: 39 additions & 1 deletion electron/main/ipc/fs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { promises as fsp } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { dirname, extname, join, resolve } from "node:path";
import { shell } from "electron";
import { mimeForImageExt } from "../../../src/types/image";
import type { FileEntry } from "../../../src/types/workspace";
import { createEntryFilter } from "../utils/entry-filter";
import { FsError, isErrnoCode } from "../utils/fs-errors";
Expand All @@ -11,6 +12,7 @@ import {
consumeTransientWritePath,
findContainingWorkspaceRoot,
} from "../utils/path-guard";
import { StructuredError } from "../utils/structured-error";
import { getFileTreeFilterOptions } from "./settings";

// fs:read のサイズ上限。`.md` は通常 1MB 未満なので 64MB は十分なマージン。
Expand Down Expand Up @@ -87,6 +89,40 @@ async function readFileImpl(senderId: number, path: string): Promise<string> {
}
}

// exportAsHtml の data URI 埋め込み用に、workspace 内の画像を base64 で読む (#314)。
// - path-guard で workspace 外 read を拒否する (fs:read と同じ保証)
// - 拡張子を image ホワイトリストで絞る (mimeForImageExt が null → reject)。
// 任意の binary を base64 で吸い上げられる能力を封じ、能力最小化する
// - サイズ上限は fs:read と共通 (64MB)。巨大画像は data URI 化しても外部ブラウザで
// 持たない (HTML file 自体が肥大化) ため、fail-loud で拒否するのが正解
async function readFileBase64Impl(senderId: number, path: string): Promise<string> {
const canonical = await assertPathAllowed(senderId, path);
if (mimeForImageExt(extname(canonical)) === null) {
throw new StructuredError(
"INVALID_PATH",
`readFileBase64: unsupported extension: ${extname(canonical) || "(none)"}`,
{ path: canonical },
);
}
const fh = await fsp.open(canonical, "r");
try {
const stat = await fh.stat();
if (stat.size > MAX_READ_FILE_BYTES) {
throw FsError.tooLarge(canonical, stat.size, MAX_READ_FILE_BYTES);
}
const buf = Buffer.alloc(stat.size);
let total = 0;
while (total < stat.size) {
const { bytesRead } = await fh.read(buf, total, stat.size - total);
if (bytesRead === 0) break;
total += bytesRead;
}
return buf.subarray(0, total).toString("base64");
} finally {
await fh.close();
}
}

async function writeFileImpl(senderId: number, path: string, content: string): Promise<void> {
const canonical = await assertWritePathAllowed(senderId, path);
await fsp.mkdir(dirname(canonical), { recursive: true });
Expand Down Expand Up @@ -212,6 +248,7 @@ async function deleteEntryImpl(senderId: number, path: string): Promise<void> {

export function registerFsIpc(): void {
handle("fs:read", (event, path: string) => readFileImpl(event.sender.id, path));
handle("fs:read-base64", (event, path: string) => readFileBase64Impl(event.sender.id, path));
handle("fs:write", (event, path: string, content: string) =>
writeFileImpl(event.sender.id, path, content),
);
Expand All @@ -235,6 +272,7 @@ export function registerFsIpc(): void {

export const __testing = {
readFileImpl,
readFileBase64Impl,
readFileBoundedFromHandle,
writeFileImpl,
writeNewFileImpl,
Expand Down
1 change: 1 addition & 0 deletions electron/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type Api = Readonly<{
workspaceSet: (path: string | null) => Promise<void>;

readFile: (path: string) => Promise<string>;
readFileBase64: (path: string) => Promise<string>;
writeFile: (path: string, content: string) => Promise<void>;
writeNewFile: (path: string, content: string) => Promise<void>;
listDirectory: (path: string, opts?: ListDirectoryOptions) => Promise<FileEntry[]>;
Expand Down
1 change: 1 addition & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const api: Api = Object.freeze({
workspaceSet: (path) => invoke(ipcRenderer.invoke("workspace:set", path)),

readFile: (path) => invoke(ipcRenderer.invoke("fs:read", path)),
readFileBase64: (path) => invoke(ipcRenderer.invoke("fs:read-base64", path)),
writeFile: (path, content) => invoke(ipcRenderer.invoke("fs:write", path, content)),
writeNewFile: (path, content) => invoke(ipcRenderer.invoke("fs:write-new", path, content)),
listDirectory: (path, opts) => invoke(ipcRenderer.invoke("fs:list", path, opts)),
Expand Down
1 change: 1 addition & 0 deletions src/__test-utils__/api-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function createApiMock(): Api {
workspaceSet: vi.fn(async () => {}),

readFile: vi.fn(async () => ""),
readFileBase64: vi.fn(async () => ""),
writeFile: vi.fn(async () => {}),
writeNewFile: vi.fn(async () => {}),
listDirectory: vi.fn(async () => []),
Expand Down
8 changes: 8 additions & 0 deletions src/lib/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export function readFile(path: string): Promise<string> {
return withRetry(() => window.api.readFile(path));
}

/**
* ワークスペース内の画像ファイルを base64 で読む (exportAsHtml の data URI 埋め込み用、#314)。
* main 側で拡張子ホワイトリスト + workspace 内 path guard により、任意 binary の吸い上げは不可。
*/
export function readFileBase64(path: string): Promise<string> {
return withRetry(() => window.api.readFileBase64(path));
}

export function writeFile(path: string, content: string): Promise<void> {
return withRetry(() => window.api.writeFile(path, content));
}
Expand Down
38 changes: 37 additions & 1 deletion src/lib/export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ vi.mock("./commands", async () => {
// resolveHtmlImageSrcs 経由の resolveImageSrc が呼ぶ。既存 image-src.test.ts と
// 同一の production 実装で mock (mock ドリフト防止)。
buildAssetUrl: (path: string) => buildScriptaAssetUrl(path),
// exportAsHtml が呼ぶ #314 data URI 埋め込み経路。default は「常に失敗」で
// 元の src を残し、必要なテストだけ mockImplementation で成功挙動を上書きする。
readFileBase64: vi.fn().mockRejectedValue(new Error("mock: not stubbed")),
};
});

Expand All @@ -20,7 +23,7 @@ vi.mock("./svg-rasterize", () => ({
svgToPng: vi.fn(async () => "data:image/png;base64,MOCK"),
}));

const { writeFile, exportPdf, showSaveDialog } = await import("./commands");
const { writeFile, exportPdf, showSaveDialog, readFileBase64 } = await import("./commands");
const {
buildHtmlDocument,
buildPromptFromTemplate,
Expand All @@ -39,6 +42,7 @@ const {
const mockedSave = showSaveDialog as Mock;
const mockedWriteFile = writeFile as Mock;
const mockedExportPdf = exportPdf as Mock;
const mockedReadFileBase64 = readFileBase64 as Mock;

describe("exportAsHtml", () => {
beforeEach(() => {
Expand Down Expand Up @@ -122,6 +126,38 @@ describe("exportAsHtml", () => {
expect(html).toContain("mermaid-diagram");
expect(html).not.toContain("```mermaid");
});

it("embeds local relative images as data URI (#314)", async () => {
mockedSave.mockResolvedValue("/output/test.html");
mockedReadFileBase64.mockImplementation(async (path: string) => {
expect(path).toBe("/workspace/img/hero.png");
return "AAAA";
});
await exportAsHtml("![alt](./img/hero.png)", "/workspace/test.md");
const html = mockedWriteFile.mock.calls[0][1] as string;
expect(html).toContain('src="data:image/png;base64,AAAA"');
expect(html).not.toContain('src="./img/hero.png"');
// 外部ブラウザで解決できない scripta-asset:// も混入しないこと
expect(html).not.toContain("scripta-asset://");
});

it("leaves http(s) src untouched (no fetch attempt)", async () => {
mockedSave.mockResolvedValue("/output/test.html");
await exportAsHtml("![](https://example.com/x.png)", "/workspace/test.md");
const html = mockedWriteFile.mock.calls[0][1] as string;
expect(html).toContain('src="https://example.com/x.png"');
expect(mockedReadFileBase64).not.toHaveBeenCalled();
});

it("keeps original src when readFileBase64 fails (broken image, but export succeeds)", async () => {
mockedSave.mockResolvedValue("/output/test.html");
mockedReadFileBase64.mockRejectedValue(new Error("EACCES"));
const result = await exportAsHtml("![](./missing.png)", "/workspace/test.md");
expect(result).toBe(true);
const html = mockedWriteFile.mock.calls[0][1] as string;
// data: に置換されず元 src が残る (browser は broken image を表示するが export は完遂)
expect(html).toContain('src="./missing.png"');
});
});

describe("exportAsPrompt", () => {
Expand Down
7 changes: 5 additions & 2 deletions src/lib/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { collectRawCodeRanges, isInsideRanges, markdownToHtml } from "./markdown
// この経路を共有する)。既存 API 互換のため下記で re-export する。
import { preprocessMermaidBlocks } from "./mermaid-preprocess";
import { basename } from "./path";
import { resolveHtmlImageSrcs } from "./resolve-html-images";
import { embedHtmlImagesAsDataUri, resolveHtmlImageSrcs } from "./resolve-html-images";
import { extractSlideFrontmatterTheme, parseSlides } from "./slide-parser";
import { renderSlideHtmlWithMermaid } from "./slide-render";

Expand Down Expand Up @@ -320,7 +320,10 @@ export async function exportAsHtml(
const mermaidTheme = resolveMermaidTheme(options?.theme);
const withMarkers = preprocessPageBreakMarkers(markdown);
const preprocessed = await preprocessMermaidBlocks(withMarkers, mermaidTheme);
const bodyHtml = markdownToHtml(preprocessed);
// 相対 / 絶対 workspace パスのローカル画像を data URI として埋め込む (#314)。
// scripta-asset:// では外部ブラウザから解決不能なため、HTML 単体で self-contained
// にするにはインライン化が必要。activeTabPath は書き出し元の md path で代用する。
const bodyHtml = await embedHtmlImagesAsDataUri(markdownToHtml(preprocessed), filePath);
// Mermaid SVG は固定テーマでレンダリングされるため、
// system の場合も解決済みテーマで HTML 全体を統一する
const htmlTheme = options?.theme === "system" || !options?.theme ? mermaidTheme : options.theme;
Expand Down
28 changes: 27 additions & 1 deletion src/lib/image-src.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { buildScriptaAssetUrl } from "../../electron/preload/scripta-asset-url";
import { parentDir, resolveImageSrc } from "./image-src";
import { parentDir, resolveImageSrc, resolveImageToOsPath } from "./image-src";

vi.mock("./commands", () => ({
// production と同一ロジックでモックする。preload の `buildAssetUrl` も同じ helper を
Expand Down Expand Up @@ -136,3 +136,29 @@ describe("resolveImageSrc", () => {
expect(resolveImageSrc("image.png", "note.md")).toBe("image.png");
});
});

describe("resolveImageToOsPath", () => {
it("returns null for http(s)", () => {
expect(resolveImageToOsPath("http://a.example/x.png", null)).toBeNull();
expect(resolveImageToOsPath("https://a.example/x.png", null)).toBeNull();
});

it("returns null for data:/blob:", () => {
expect(resolveImageToOsPath("data:image/png;base64,AAAA", null)).toBeNull();
expect(resolveImageToOsPath("blob:file:///abc", null)).toBeNull();
});

it("returns absolute unix path unchanged", () => {
expect(resolveImageToOsPath("/workspace/logo.svg", null)).toBe("/workspace/logo.svg");
});

it("resolves relative path against activeTabPath", () => {
expect(resolveImageToOsPath("./img/hero.png", "/workspace/notes/deck.md")).toBe(
"/workspace/notes/img/hero.png",
);
});

it("returns null when activeTabPath is null and src is relative", () => {
expect(resolveImageToOsPath("image.png", null)).toBeNull();
});
});
39 changes: 21 additions & 18 deletions src/lib/image-src.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,34 @@ function detectSeparator(filePath: string): string {
return lastBackslash > lastSlash ? "\\" : "/";
}

export function resolveImageSrc(
rawUrl: string,
activeTabPath: string | null = useWorkspaceStore.getState().activeTabPath,
): string {
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) {
return rawUrl;
}
// data: / blob: は既に自己完結の URL なのでそのまま返す。
// これらを相対パス分岐に落とすと activeTabPath 配下として scripta-asset URL に
// 巻き取られ、画像が壊れる。
if (rawUrl.startsWith("data:") || rawUrl.startsWith("blob:")) {
return rawUrl;
}
/**
* markdown img の src (相対 / 絶対 / http(s) / data / blob) を OS 絶対パスに解決する。
* 解決できない (remote scheme / self-contained scheme / activeTabPath 不在で相対) 場合は
* null。exportAsHtml の data URI 埋め込み経路 (#314) と `resolveImageSrc` の共通コア。
*/
export function resolveImageToOsPath(rawUrl: string, activeTabPath: string | null): string | null {
if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) return null;
if (rawUrl.startsWith("data:") || rawUrl.startsWith("blob:")) return null;
if (rawUrl.startsWith("/") || rawUrl.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(rawUrl)) {
return buildAssetUrl(rawUrl);
return rawUrl;
}
if (!activeTabPath) return rawUrl;
if (!activeTabPath) return null;
const dir = parentDir(activeTabPath);
if (!dir) return rawUrl;
if (!dir) return null;
let normalized = rawUrl;
if (normalized.startsWith("./") || normalized.startsWith(".\\")) {
normalized = normalized.slice(2);
}
const sep = detectSeparator(activeTabPath);
const needsSep = !dir.endsWith("/") && !dir.endsWith("\\");
const resolved = needsSep ? `${dir}${sep}${normalized}` : `${dir}${normalized}`;
return buildAssetUrl(resolved);
return needsSep ? `${dir}${sep}${normalized}` : `${dir}${normalized}`;
}

export function resolveImageSrc(
rawUrl: string,
activeTabPath: string | null = useWorkspaceStore.getState().activeTabPath,
): string {
const osPath = resolveImageToOsPath(rawUrl, activeTabPath);
if (osPath === null) return rawUrl;
return buildAssetUrl(osPath);
}
Loading
Loading