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
137 changes: 137 additions & 0 deletions apps/desktop/scripts/pdf-windows-paths.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, expect, it, vi } from "vitest";
import { buildVaultPreviewUrlFromAbsolutePath } from "../src/app/utils/filePreviewUrl";
import { toVaultRelativePath } from "../src/app/utils/vaultPaths";

// Only Electron's host APIs are mocked. The preview handler, filesystem,
// frontend URL builder, Rust canonicalization and PDF.js are real.
vi.mock("electron", () => ({
app: { isPackaged: true },
BrowserWindow: {},
dialog: {},
ipcMain: {},
shell: {},
}));

import { registerPreviewProtocolHandler } from "../src-electron/main/ipc";

let scratch: string;
let canonicalizer: string;

function createPdf() {
const stream = "0 0 1 rg\n10 10 30 30 re f\n";
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << >> /Contents 4 0 R >>",
`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}endstream`,
];
let pdf = "%PDF-1.4\n";
const offsets = [0];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(pdf));
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
for (const offset of offsets.slice(1)) {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
}
pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(pdf);
}

beforeAll(async () => {
scratch = await fs.mkdtemp(path.join(os.tmpdir(), "neverwrite-pdf-519-"));
const source = path.join(scratch, "canonicalize.rs");
canonicalizer = path.join(
scratch,
process.platform === "win32" ? "canonicalize.exe" : "canonicalize",
);
// Same operations as the native backend: canonicalize the vault root,
// then return a discovered file's absolute path under that root.
await fs.writeFile(source, `
fn main() -> std::io::Result<()> {
let mut args = std::env::args_os().skip(1);
let root = std::fs::canonicalize(args.next().expect("vault path"))?;
let file = root.join(args.next().expect("relative file path"));
println!("{}", root.display());
println!("{}", file.display());
Ok(())
}
`);
execFileSync("rustc", ["--edition=2021", source, "-o", canonicalizer], {
timeout: 45_000,
stdio: "pipe",
});
});

afterAll(async () => {
if (scratch) await fs.rm(scratch, { recursive: true, force: true });
});

it.each(["Local Vault", "OneDrive/Universidad José/My Vault"])(
"loads a PDF through real Rust paths and the preview handler: %s",
async (directory) => {
// A local directory with OneDrive-like naming, not a live cloud mount.
const vault = path.join(scratch, directory);
const relativePath = "docs/Asymptoter.pdf";
const bytes = createPdf();
await fs.mkdir(path.join(vault, "docs"), { recursive: true });
await fs.writeFile(path.join(vault, relativePath), bytes);

const [canonicalVault, canonicalFile] = execFileSync(
canonicalizer,
[vault, relativePath],
{ encoding: "utf8", timeout: 10_000 },
).trimEnd().split(/\r?\n/);
expect(toVaultRelativePath(canonicalFile, canonicalVault)).toBe(relativePath);

if (process.platform === "win32") {
expect(canonicalVault.startsWith("\\\\?\\")).toBe(true);
// Demonstrate the old failure on paths produced by Windows itself.
const oldPathname = canonicalFile.split(/[?#]/, 1)[0];
expect(toVaultRelativePath(oldPathname, canonicalVault)).toBeNull();
}

const previewUrl = buildVaultPreviewUrlFromAbsolutePath(
canonicalFile,
canonicalVault,
);
console.info(JSON.stringify({
platform: process.platform,
canonicalVault,
canonicalFile,
previewUrl,
}));
expect(previewUrl).not.toBeNull();
const handler = registerPreviewProtocolHandler({ invoke: vi.fn() } as never);
const response = await handler(new Request(previewUrl!));
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/pdf");
const data = new Uint8Array(await response.arrayBuffer());
expect(Buffer.from(data)).toEqual(bytes);

const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const loadingTask = pdfjs.getDocument({
data,
isImageDecoderSupported: false,
isOffscreenCanvasSupported: false,
stopAtErrors: true,
useSystemFonts: true,
verbosity: pdfjs.VerbosityLevel.ERRORS,
});
try {
const pdf = await loadingTask.promise;
expect(pdf.numPages).toBe(1);
const page = await pdf.getPage(1);
const operators = await page.getOperatorList();
expect(operators.fnArray.length).toBeGreaterThan(0);
} finally {
await loadingTask.destroy();
}
},
);
11 changes: 11 additions & 0 deletions apps/desktop/scripts/pdf-windows-paths.vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";

// Temporary issue #519 investigation: real filesystem and Rust, no DOM mocks.
export default defineConfig({
test: {
environment: "node",
include: ["scripts/pdf-windows-paths.integration.test.ts"],
hookTimeout: 60_000,
testTimeout: 30_000,
},
});
47 changes: 47 additions & 0 deletions apps/desktop/src/app/utils/filePreviewUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,53 @@ describe("filePreviewUrl", () => {
)?.toContain("?raw=1");
});

it.each([
String.raw`\\?\C:\Users\José\OneDrive\My Vault`,
"//?/C:/Users/José/OneDrive/My Vault",
String.raw`\\?\UNC\server\share\My Vault`,
"//?/UNC/server/share/My Vault",
])("preserves the Windows namespace in %s", (vaultPath) => {
const separator = vaultPath.includes("\\") ? "\\" : "/";
const filePath = `${vaultPath}${separator}docs${separator}Asymptoter.pdf`;
const expected = buildVaultPreviewUrl(vaultPath, "docs/Asymptoter.pdf");

expect(buildVaultPreviewUrlFromAbsolutePath(filePath, vaultPath)).toBe(
expected,
);
expect(
buildVaultPreviewUrlFromAbsolutePath(
`${filePath}?raw=1#page=2`,
vaultPath,
),
).toBe(`${expected}?raw=1#page=2`);
expect(
buildVaultPreviewUrlFromAbsolutePath(`${filePath}#page=2`, vaultPath),
).toBe(`${expected}#page=2`);
expect(isAuthorizedVaultPreviewPath(filePath, vaultPath)).toBe(filePath);
expect(
isAuthorizedVaultPreviewPath(`${filePath}?raw=1`, vaultPath),
).toBe(filePath);

const outsidePath = `${vaultPath}-other${separator}Asymptoter.pdf`;
expect(
buildVaultPreviewUrlFromAbsolutePath(outsidePath, vaultPath),
).toBeNull();
expect(isAuthorizedVaultPreviewPath(outsidePath, vaultPath)).toBeNull();
});

it("preserves Windows namespaces in generated image previews", () => {
const imagePath = String.raw`\\?\C:\Users\José\.codex\generated_images\image.png`;
const preview = buildCodexGeneratedImagePreviewUrl(`${imagePath}#preview`)!;
const url = new URL(preview);
const encodedPath = url.pathname.split("/").at(-1)!;

expect(Buffer.from(encodedPath, "base64url").toString("utf8")).toBe(
imagePath,
);
expect(url.search).toBe("");
expect(url.hash).toBe("#preview");
});

it("rejects absolute paths outside the active vault", () => {
expect(
buildVaultPreviewUrlFromAbsolutePath(
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/app/utils/filePreviewUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ function encodeBase64Url(value: string) {
}

function splitPathSuffix(value: string) {
const marker = value.search(/[?#]/);
// Rust canonicalizes Windows paths to \\?\C:\... (or \\?\UNC\...).
// The namespace's question mark is part of the path, not a URL query.
const prefixLength = /^(?:\\\\\?\\|\/\/\?\/)/.test(value) ? 4 : 0;
const suffixOffset = value.slice(prefixLength).search(/[?#]/);
const marker = suffixOffset === -1 ? -1 : prefixLength + suffixOffset;
return marker === -1
? { pathname: value, suffix: "" }
: {
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/features/pdf/PdfTabView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "../../test/test-utils";
import { useEditorStore } from "../../app/store/editorStore";
import { useSettingsStore } from "../../app/store/settingsStore";
import { useVaultStore } from "../../app/store/vaultStore";

const { getDocumentMock } = vi.hoisted(() => ({
getDocumentMock: vi.fn(),
Expand Down Expand Up @@ -187,6 +188,46 @@ describe("PdfTabView", () => {
).not.toBeInTheDocument();
});

it("loads a PDF under a Windows canonical vault path", async () => {
const vaultPath = String.raw`\\?\C:\Users\José\OneDrive\My Vault`;
useVaultStore.setState({ vaultPath });
const page = createMockPage();
getDocumentMock.mockReturnValue({
destroy: vi.fn(),
promise: Promise.resolve({
destroy: vi.fn(),
getPage: vi.fn().mockResolvedValue(page),
numPages: 1,
}),
});
setEditorTabs([
{
kind: "pdf",
id: "pdf-tab",
entryId: "Asymptoter.pdf",
title: "Asymptoter",
path: `${vaultPath}\\Asymptoter.pdf`,
page: 1,
zoom: 1,
viewMode: "single",
},
]);

renderComponent(<PdfTabView />);

await waitFor(() => expect(page.render).toHaveBeenCalled());
const url = new URL(getDocumentMock.mock.calls[0][0].url);
const [, scope, encodedVault, encodedPath] = url.pathname.split("/");
expect(scope).toBe("vault");
expect(Buffer.from(encodedVault, "base64url").toString("utf8")).toBe(
vaultPath,
);
expect(Buffer.from(encodedPath, "base64url").toString("utf8")).toBe(
"Asymptoter.pdf",
);
expect(screen.queryByText("Failed to load PDF")).not.toBeInTheDocument();
});

it("explains when the PDF is outside the active vault", async () => {
setEditorTabs([
{
Expand Down
Loading