Skip to content

Commit 9c399c7

Browse files
Filmbostock
andauthored
build filters files outside the root (#103)
* build filters files outside the root reverts part of #89 fixes #99 * fix tests and test imports of non-existing files * more tests but I'm not sure if I'm using this correctly * Update test/input/bar.js Co-authored-by: Mike Bostock <[email protected]> * fix test * fix test, align signatures * don’t canReadSync in isLocalPath * syntax error on non-local file path --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent 48c20d1 commit 9c399c7

13 files changed

+277
-30
lines changed

src/files.ts

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,14 @@
1-
import {type Stats, accessSync, constants, statSync} from "node:fs";
1+
import {type Stats} from "node:fs";
22
import {mkdir, readdir, stat} from "node:fs/promises";
33
import {dirname, extname, join, normalize, relative} from "node:path";
44
import {isNodeError} from "./error.js";
55

6-
// A file is local if it exists in the root folder or a subfolder.
7-
export function isLocalFile(ref: string | null, root: string): boolean {
8-
return (
9-
typeof ref === "string" &&
10-
!/^(\w+:)\/\//.test(ref) &&
11-
!normalize(ref).startsWith("../") &&
12-
canReadSync(join(root, ref))
13-
);
14-
}
15-
16-
export function pathFromRoot(ref: string | null, root: string): string | null {
17-
return isLocalFile(ref, root) ? join(root, ref!) : null;
18-
}
19-
20-
function canReadSync(path: string): boolean {
21-
try {
22-
accessSync(path, constants.R_OK);
23-
return statSync(path).isFile();
24-
} catch (error) {
25-
if (isNodeError(error) && error.code === "ENOENT") return false;
26-
throw error;
27-
}
6+
// A path is local if it doesn’t go outside the the root.
7+
export function getLocalPath(sourcePath: string, name: string): string | null {
8+
if (/^(\w+:)\/\//.test(name)) return null; // URL
9+
const path = join(dirname(sourcePath.startsWith("/") ? sourcePath.slice("/".length) : sourcePath), name);
10+
if (path.startsWith("../")) return null; // goes above root
11+
return path;
2812
}
2913

3014
export async function* visitMarkdownFiles(root: string): AsyncGenerator<string> {

src/javascript/features.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {simple} from "acorn-walk";
2+
import {getLocalPath} from "../files.js";
23
import {type Feature} from "../javascript.js";
34
import {isLocalImport} from "./imports.js";
45
import {syntaxError} from "./syntaxError.js";
@@ -36,7 +37,13 @@ export function findFeatures(node, root, sourcePath, references, input) {
3637
throw syntaxError(`${callee.name} requires a single literal string argument`, node, input);
3738
}
3839

39-
features.push({type: callee.name, name: getStringLiteralValue(arg)});
40+
// Forbid file attachments that are not local paths.
41+
const value = getStringLiteralValue(arg);
42+
if (callee.name === "FileAttachment" && !getLocalPath(sourcePath, value)) {
43+
throw syntaxError(`non-local file path: "${value}"`, node, input);
44+
}
45+
46+
features.push({type: callee.name, name: value});
4047
}
4148
});
4249

src/markdown.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {readFile} from "node:fs/promises";
2-
import {dirname, join} from "node:path";
2+
import {join} from "node:path";
33
import {type Patch, type PatchItem, getPatch} from "fast-array-diff";
44
import equal from "fast-deep-equal";
55
import matter from "gray-matter";
@@ -11,7 +11,7 @@ import {type RuleInline} from "markdown-it/lib/parser_inline.js";
1111
import {type RenderRule, type default as Renderer} from "markdown-it/lib/renderer.js";
1212
import MarkdownItAnchor from "markdown-it-anchor";
1313
import mime from "mime";
14-
import {isLocalFile, pathFromRoot} from "./files.js";
14+
import {getLocalPath} from "./files.js";
1515
import {computeHash} from "./hash.js";
1616
import {type FileReference, type ImportReference, type Transpile, transpileJavaScript} from "./javascript.js";
1717
import {transpileTag} from "./tag.js";
@@ -322,8 +322,8 @@ function normalizePieceHtml(html: string, root: string, sourcePath: string, cont
322322
const {document} = parseHTML(html);
323323
for (const element of document.querySelectorAll("link[href]") as any as Iterable<Element>) {
324324
const href = element.getAttribute("href")!;
325-
const path = join(dirname(sourcePath), href);
326-
if (isLocalFile(path, root)) {
325+
const path = getLocalPath(sourcePath, href);
326+
if (path) {
327327
context.files.push({name: href, mimeType: mime.getType(href)});
328328
element.setAttribute("href", `/_file/${path}`);
329329
}
@@ -463,6 +463,6 @@ export function diffMarkdown({parse: prevParse}: ReadMarkdownResult, {parse: nex
463463
}
464464

465465
export async function readMarkdown(path: string, root: string): Promise<ReadMarkdownResult> {
466-
const contents = await readFile(pathFromRoot(path, root)!, "utf-8");
466+
const contents = await readFile(join(root, path), "utf-8");
467467
return {contents, parse: parseMarkdown(contents, root, path), hash: computeHash(contents)};
468468
}

src/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ function handleWatch(socket: WebSocket, options: {root: string; resolver: CellRe
260260
let {path} = message;
261261
if (!(path = normalize(path)).startsWith("/")) throw new Error("Invalid path: " + path);
262262
if (path.endsWith("/")) path += "index";
263-
path = path.slice("/".length) + ".md";
263+
path += ".md";
264264
markdownWatcher = watch(join(root, path), await refreshMarkdown(path));
265265
break;
266266
}

test/input/bar.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const bar = Symbol("bar");

test/input/dynamic-import-noent.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
const foo = await import("./noent.js");

test/input/fetch-parent-dir.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Parent dir
2+
3+
Trying to fetch from the parent directory should fail.
4+
5+
```js
6+
const fail1 = fetch("../NOENT.md").then(d => d.text())
7+
```
8+
9+
```js
10+
const fail2 = FileAttachment("../NOENT.md").text()
11+
```
12+
13+
```js
14+
const fail3 = fetch("../README.md").then(d => d.text())
15+
```
16+
17+
```js
18+
const fail4 = FileAttachment("../README.md").text()
19+
```
20+
21+
```js
22+
const fail5 = fetch("./NOENT.md").then(d => d.text())
23+
```
24+
25+
```js
26+
const fail6 = FileAttachment("./NOENT.md").text()
27+
```
28+
29+
```js
30+
const ok1 = fetch("./tex-expression.md").then(d => d.text())
31+
```
32+
33+
```js
34+
const ok2 = FileAttachment("./tex-expression.md").text()
35+
```
36+

test/input/static-import-noent.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {foo} from "./noent.js";
2+
3+
display(foo);

test/output/bar.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
define({id: "0", outputs: ["bar"], body: () => {
2+
const bar = Symbol("bar");
3+
return {bar};
4+
}});

test/output/dynamic-import-noent.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
define({id: "0", outputs: ["foo"], body: async () => {
2+
const foo = await import("/_import/noent.js");
3+
return {foo};
4+
}});

test/output/fetch-parent-dir.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<h1 id="parent-dir" tabindex="-1"><a class="observablehq-header-anchor" href="#parent-dir">Parent dir</a></h1>
2+
<p>Trying to fetch from the parent directory should fail.</p>
3+
<div id="cell-7f64da9c" class="observablehq observablehq--block"></div>
4+
<div id="cell-02cc5429" class="observablehq observablehq--block"></div>
5+
<div id="cell-506a5b26" class="observablehq observablehq--block"></div>
6+
<div id="cell-4078d1e8" class="observablehq observablehq--block"></div>
7+
<div id="cell-4043aea7" class="observablehq observablehq--block"></div>
8+
<div id="cell-96f2427a" class="observablehq observablehq--block"></div>
9+
<div id="cell-57d7a0f9" class="observablehq observablehq--block"></div>
10+
<div id="cell-207b0b42" class="observablehq observablehq--block"></div>

test/output/fetch-parent-dir.json

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
{
2+
"data": null,
3+
"title": "Parent dir",
4+
"files": [
5+
{
6+
"name": "./NOENT.md",
7+
"mimeType": "text/markdown"
8+
},
9+
{
10+
"name": "./NOENT.md",
11+
"mimeType": "text/markdown"
12+
},
13+
{
14+
"name": "./tex-expression.md",
15+
"mimeType": "text/markdown"
16+
},
17+
{
18+
"name": "./tex-expression.md",
19+
"mimeType": "text/markdown"
20+
}
21+
],
22+
"imports": [],
23+
"pieces": [
24+
{
25+
"type": "html",
26+
"id": "",
27+
"cellIds": [],
28+
"html": "<h1 id=\"parent-dir\" tabindex=\"-1\"><a class=\"observablehq-header-anchor\" href=\"#parent-dir\">Parent dir</a></h1>\n"
29+
},
30+
{
31+
"type": "html",
32+
"id": "",
33+
"cellIds": [],
34+
"html": "<p>Trying to fetch from the parent directory should fail.</p>\n"
35+
},
36+
{
37+
"type": "html",
38+
"id": "",
39+
"cellIds": [
40+
"7f64da9c"
41+
],
42+
"html": "<div id=\"cell-7f64da9c\" class=\"observablehq observablehq--block\"></div>\n"
43+
},
44+
{
45+
"type": "html",
46+
"id": "",
47+
"cellIds": [
48+
"02cc5429"
49+
],
50+
"html": "<div id=\"cell-02cc5429\" class=\"observablehq observablehq--block\"></div>\n"
51+
},
52+
{
53+
"type": "html",
54+
"id": "",
55+
"cellIds": [
56+
"506a5b26"
57+
],
58+
"html": "<div id=\"cell-506a5b26\" class=\"observablehq observablehq--block\"></div>\n"
59+
},
60+
{
61+
"type": "html",
62+
"id": "",
63+
"cellIds": [
64+
"4078d1e8"
65+
],
66+
"html": "<div id=\"cell-4078d1e8\" class=\"observablehq observablehq--block\"></div>\n"
67+
},
68+
{
69+
"type": "html",
70+
"id": "",
71+
"cellIds": [
72+
"4043aea7"
73+
],
74+
"html": "<div id=\"cell-4043aea7\" class=\"observablehq observablehq--block\"></div>\n"
75+
},
76+
{
77+
"type": "html",
78+
"id": "",
79+
"cellIds": [
80+
"96f2427a"
81+
],
82+
"html": "<div id=\"cell-96f2427a\" class=\"observablehq observablehq--block\"></div>\n"
83+
},
84+
{
85+
"type": "html",
86+
"id": "",
87+
"cellIds": [
88+
"57d7a0f9"
89+
],
90+
"html": "<div id=\"cell-57d7a0f9\" class=\"observablehq observablehq--block\"></div>\n"
91+
},
92+
{
93+
"type": "html",
94+
"id": "",
95+
"cellIds": [
96+
"207b0b42"
97+
],
98+
"html": "<div id=\"cell-207b0b42\" class=\"observablehq observablehq--block\"></div>\n"
99+
}
100+
],
101+
"cells": [
102+
{
103+
"type": "cell",
104+
"id": "7f64da9c",
105+
"outputs": [
106+
"fail1"
107+
],
108+
"body": "() => {\nconst fail1 = fetch(\"../NOENT.md\").then(d => d.text())\nreturn {fail1};\n}"
109+
},
110+
{
111+
"type": "cell",
112+
"id": "02cc5429",
113+
"body": "() => { throw new SyntaxError(\"non-local file path: \\\"../NOENT.md\\\" (1:14)\"); }"
114+
},
115+
{
116+
"type": "cell",
117+
"id": "506a5b26",
118+
"outputs": [
119+
"fail3"
120+
],
121+
"body": "() => {\nconst fail3 = fetch(\"../README.md\").then(d => d.text())\nreturn {fail3};\n}"
122+
},
123+
{
124+
"type": "cell",
125+
"id": "4078d1e8",
126+
"body": "() => { throw new SyntaxError(\"non-local file path: \\\"../README.md\\\" (1:14)\"); }"
127+
},
128+
{
129+
"type": "cell",
130+
"id": "4043aea7",
131+
"outputs": [
132+
"fail5"
133+
],
134+
"files": [
135+
{
136+
"name": "./NOENT.md",
137+
"mimeType": "text/markdown"
138+
}
139+
],
140+
"body": "() => {\nconst fail5 = fetch(\"./_file/NOENT.md\").then(d => d.text())\nreturn {fail5};\n}"
141+
},
142+
{
143+
"type": "cell",
144+
"id": "96f2427a",
145+
"inputs": [
146+
"FileAttachment"
147+
],
148+
"outputs": [
149+
"fail6"
150+
],
151+
"files": [
152+
{
153+
"name": "./NOENT.md",
154+
"mimeType": "text/markdown"
155+
}
156+
],
157+
"body": "(FileAttachment) => {\nconst fail6 = FileAttachment(\"./NOENT.md\").text()\nreturn {fail6};\n}"
158+
},
159+
{
160+
"type": "cell",
161+
"id": "57d7a0f9",
162+
"outputs": [
163+
"ok1"
164+
],
165+
"files": [
166+
{
167+
"name": "./tex-expression.md",
168+
"mimeType": "text/markdown"
169+
}
170+
],
171+
"body": "() => {\nconst ok1 = fetch(\"./_file/tex-expression.md\").then(d => d.text())\nreturn {ok1};\n}"
172+
},
173+
{
174+
"type": "cell",
175+
"id": "207b0b42",
176+
"inputs": [
177+
"FileAttachment"
178+
],
179+
"outputs": [
180+
"ok2"
181+
],
182+
"files": [
183+
{
184+
"name": "./tex-expression.md",
185+
"mimeType": "text/markdown"
186+
}
187+
],
188+
"body": "(FileAttachment) => {\nconst ok2 = FileAttachment(\"./tex-expression.md\").text()\nreturn {ok2};\n}"
189+
}
190+
]
191+
}

test/output/static-import-noent.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
define({id: "0", inputs: ["display"], outputs: ["foo"], body: async (display) => {
2+
const {foo} = await import("/_import/noent.js");
3+
4+
display(foo);
5+
return {foo};
6+
}});

0 commit comments

Comments
 (0)