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
41 changes: 37 additions & 4 deletions packages/studio-server/src/helpers/previewReads.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { readlinkSync } from "node:fs";
import { dirname, join, parse, relative, resolve } from "node:path";
import { parseHTML } from "linkedom";
import { STUDIO_SIGNATURE_MANIFEST_PATHS } from "./projectSignature.js";
import { realFilePath } from "./safePath.js";

const ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
// A malformed inline URL must not turn ancestor tracking into an image-sized path walk.
const MAX_PREVIEW_PATH_LENGTH = 4096;
const SHOW_ELEMENT = 1;
const REFERENCE =
/\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
const REFERENCE_ATTRIBUTE = /\b(?:src|href|poster|data-composition-src)$/i;
const isElement = (node: Node): node is Element => node.nodeType === 1;
// macOS and Windows volumes ignore letter case by default, so it is not part of a path's identity there.
const pathKey =
process.platform === "darwin" || process.platform === "win32"
Expand Down Expand Up @@ -39,6 +45,7 @@ function linksOnTheWay(path: string): string[] {
continue;
}
const next = join(at, part);
if (next.length > MAX_PREVIEW_PATH_LENGTH) return passed;
const target = hops < 40 ? readLink(next) : null;
if (target === null) {
at = next;
Expand All @@ -56,28 +63,54 @@ function linksOnTheWay(path: string): string[] {

// A link's target can be created or retargeted later, so every read resolves it again.
export function recordPreviewRead(projectDir: string, filePath: string): void {
if (filePath.length > MAX_PREVIEW_PATH_LENGTH) return;
const key = projectKey(projectDir);
let reads = readsByProject.get(key);
if (!reads) readsByProject.set(key, (reads = new Set()));
const read = inRealProject(projectDir, filePath);
if (read.length > MAX_PREVIEW_PATH_LENGTH) return;
for (const found of [read, ...linksOnTheWay(read)]) {
for (let path = pathKey(found); !reads.has(path); path = dirname(path)) reads.add(path);
}
}

export function recordPreviewReferences(projectDir: string, html: string): void {
const named = new Set<string>();
for (const match of html.matchAll(REFERENCE)) {
const url = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "").trim();
if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue;
const add = (reference: string) => {
const url = reference.trim();
if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) return;
const path = url.split(/[?#]/)[0] ?? "";
let decoded = path;
try {
decoded = decodeURIComponent(path);
} catch {
// A malformed escape names the file literally.
}
if (decoded) named.add(decoded);
if (decoded && decoded.length <= MAX_PREVIEW_PATH_LENGTH) named.add(decoded);
};
const scan = (text: string) => {
for (const match of text.matchAll(REFERENCE)) {
add(match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "");
}
};
const scanElement = (element: Element) => {
for (const attribute of element.attributes) {
if (REFERENCE_ATTRIBUTE.test(attribute.name)) add(attribute.value);
}
const style = element.getAttribute("style");
if (style) scan(style);
if (element.localName === "style" || element.localName === "script") {
scan(element.textContent ?? "");
}
};
// The parser decodes attributes; walking also visits inert template descendants without cloning them.
const { document } = parseHTML(html);
for (const root of document.children) {
scanElement(root);
const elements = document.createTreeWalker(root, SHOW_ELEMENT);
for (let node = elements.nextNode(); node; node = elements.nextNode()) {
if (isElement(node)) scanElement(node);
}
}
for (const path of named) recordPreviewRead(projectDir, path);
}
Expand Down
80 changes: 80 additions & 0 deletions packages/studio-server/src/routes/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1898,6 +1898,86 @@ describe("what the preview loaded", () => {
expect(affectsPreview(projectDir, "im")).toBe(false);
});

it("does not walk entity-quoted inline image data as a filesystem path", () => {
const projectDir = createProjectDir();
const data = `data:image/png;base64,${"A/".repeat(128)}`;
for (const quote of ["&quot;", "&apos;", "&#34;", "&#x22;", "&#39;", "&#x27;"]) {
recordPreviewReferences(
projectDir,
`<i style="background-image:url(${quote}${data}${quote})"></i>`,
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, `${quote}${data}${quote}`)).toBe(false);
}
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, `&quot;${data}&quot;`)).toBe(false);
});

it("tracks entity-quoted local image paths after decoding their HTML entities", () => {
const projectDir = createProjectDir();
recordPreviewReferences(
projectDir,
'<i style="background-image:url(&quot;assets/a&amp;b.png&quot;)"></i>',
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(true);
});

it("keeps entities literal in stylesheet URLs, as CSS raw text does", () => {
const projectDir = createProjectDir();
recordPreviewReferences(
projectDir,
"<style>i { background:url('assets/a&amp;b.png') }</style>",
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, "assets/a&amp;b.png")).toBe(true);
expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(false);
});

it("does not mistake style-looking text for an HTML style attribute", () => {
const projectDir = createProjectDir();
recordPreviewReferences(
projectDir,
`<i title='style="'></i><style>/* style=" */ i { background:url('assets/a&amp;b.png') } /* " */</style><i title='"'></i>`,
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, "assets/a&amp;b.png")).toBe(true);
expect(affectsPreview(projectDir, "assets/a&b.png")).toBe(false);
});

it("tracks references in nested templates and script-created CSS", () => {
const projectDir = createProjectDir();
recordPreviewReferences(
projectDir,
'<template><template><img src="assets/nested.png"></template></template><script>el.style.background="url(assets/script.png)"</script>',
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, "assets/nested.png")).toBe(true);
expect(affectsPreview(projectDir, "assets/script.png")).toBe(true);
});

it("bounds filesystem paths after stripping long queries and decoding escapes", () => {
const projectDir = createProjectDir();
const path = `assets/${"x/".repeat(1200)}image.png`;
const encoded = path.replace(/x/g, "%78");
recordPreviewReferences(
projectDir,
`<img src="assets/image.png?${"x".repeat(5000)}"><img src="${encoded}">`,
);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, "assets/image.png")).toBe(true);
expect(affectsPreview(projectDir, path)).toBe(true);
});

it("does not walk oversized preview references or direct read paths", () => {
const projectDir = createProjectDir();
const path = `assets/${"long/".repeat(850)}image.png`;
recordPreviewReferences(projectDir, `<img src="${path}">`);
recordPreviewRead(projectDir, path);
recordPreviewBuilt(projectDir);
expect(affectsPreview(projectDir, path)).toBe(false);
});

it.skipIf(process.platform === "win32")(
"counts an edit to the file a symlinked asset points at",
async () => {
Expand Down
Loading