beatrina 0.8.6
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/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
// plugin-store.mjs — the plugin store, ported from spike/plugins.R.
|
|
2
|
+
//
|
|
3
|
+
// A PLUGIN IS DATA A KNOWN CONSUMER READS. IT IS NEVER CODE. That sentence is
|
|
4
|
+
// the whole trust model and it is why the table of kinds is CLOSED: `csl` is
|
|
5
|
+
// XML pandoc parses, `theme` is a palette the stylesheet applies, `latex` is a
|
|
6
|
+
// preamble SOMEONE ELSE'S TeX engine runs (the one row with a deny-list, and it
|
|
7
|
+
// says so rather than inheriting the other two rows' sentence by proximity).
|
|
8
|
+
// "js" is not a row and cannot become one from a manifest.
|
|
9
|
+
//
|
|
10
|
+
// Three layers, searched in order, exactly as .libPaths() is:
|
|
11
|
+
//
|
|
12
|
+
// user R_user_dir("carmar","data")/plugins/<kind>/<id>/ the ONLY layer written
|
|
13
|
+
// system $CARMAR_PLUGIN_DIR/<kind>/<id>/ an administrator's, read-only
|
|
14
|
+
// shipped <kernel>/plugins/<kind>/<id>/ the bundle's, read-only
|
|
15
|
+
//
|
|
16
|
+
// The first OK copy of an id wins. A BROKEN copy never wins: integrity is
|
|
17
|
+
// recomputed on EVERY listing, and a payload that no longer matches its
|
|
18
|
+
// manifest is listed with the reason and used by nothing.
|
|
19
|
+
//
|
|
20
|
+
// Everything here is a function of its arguments and the file system — no
|
|
21
|
+
// socket, no frame — so host/planes/plugins.mjs owns who may ask and this file
|
|
22
|
+
// owns what the answer is. test/host-plugins.test.mjs is spike/test-plugins.R's
|
|
23
|
+
// twin, case for case.
|
|
24
|
+
//
|
|
25
|
+
// Divergence from the R, stated rather than hidden: `csl_info()` and
|
|
26
|
+
// `csl_looks_like_style()` live in spike/cite.R there and here, because the
|
|
27
|
+
// kinds table needs them at module scope and JavaScript has no `source()`.
|
|
28
|
+
// host/planes/cite.mjs re-exports them, so cite still owns "what a CSL file is"
|
|
29
|
+
// at the seam a reader looks for it.
|
|
30
|
+
|
|
31
|
+
import crypto from "node:crypto";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { appId } from "./user-dirs.mjs";
|
|
36
|
+
|
|
37
|
+
export const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
38
|
+
export const PLUGIN_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
39
|
+
export const PLUGIN_MANIFEST = "plugin.json";
|
|
40
|
+
export const PLUGIN_MANIFEST_MAX_BYTES = 65536;
|
|
41
|
+
export const PLUGIN_FETCH_TIMEOUT_S = 20;
|
|
42
|
+
export const PLUGIN_POLICY_DEFAULT_FILE = "/etc/carmar/plugin-policy.json";
|
|
43
|
+
export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/carmar/plugins";
|
|
44
|
+
|
|
45
|
+
/** A CROSS-LANGUAGE CONTRACT with `EDITOR_TOKENS` in lib/editor-themes.js. */
|
|
46
|
+
export const THEME_TOKENS = Object.freeze([
|
|
47
|
+
"--ed-bg", "--ed-fg", "--ed-gutter", "--ed-gutter-bg", "--ed-line", "--ed-sel",
|
|
48
|
+
"--ed-caret", "--ed-border", "--ed-bar", "--ed-brmatch",
|
|
49
|
+
"--rtok-com", "--rtok-doc", "--rtok-section", "--rtok-tag", "--rtok-str", "--rtok-num",
|
|
50
|
+
"--rtok-kw", "--rtok-const", "--rtok-fn", "--rtok-ns", "--rtok-arg", "--rtok-op",
|
|
51
|
+
"--rtok-infix", "--rtok-paren"]);
|
|
52
|
+
export const THEME_WASH_TOKENS = Object.freeze(["--ed-line", "--ed-sel", "--ed-brmatch"]);
|
|
53
|
+
|
|
54
|
+
/** The SECOND cross-language contract, with lib/latex-templates.js. */
|
|
55
|
+
export const LATEX_TEMPLATE_KEYS = Object.freeze(["label", "documentclass", "classOptions",
|
|
56
|
+
"citeStyle", "citePackage", "topLevel", "preamble", "description", "version"]);
|
|
57
|
+
export const LATEX_CITE_STYLES = Object.freeze(["natbib", "biblatex", "plain"]);
|
|
58
|
+
export const LATEX_TOP_LEVELS = Object.freeze(["section", "chapter"]);
|
|
59
|
+
const LATEX_CLASS_RE = /^[A-Za-z][A-Za-z0-9._-]{0,39}$/;
|
|
60
|
+
const LATEX_OPTION_RE = /^[A-Za-z0-9][A-Za-z0-9 =.,%*-]{0,79}$/;
|
|
61
|
+
const LATEX_CITE_PACKAGE_RE = /^\\usepackage(\[[^\]]{0,120}\])?\{[A-Za-z0-9,._-]{1,60}\}$/;
|
|
62
|
+
export const LATEX_PREAMBLE_MAX_LINES = 200;
|
|
63
|
+
export const LATEX_PREAMBLE_MAX_CHARS = 400;
|
|
64
|
+
|
|
65
|
+
// WHY THIS ROW HAS A DENY-LIST AND THE OTHER TWO DO NOT: a preamble is code
|
|
66
|
+
// the AUTHOR'S TeX engine runs. The boundary is `(?![A-Za-z])`, never `\b` — a
|
|
67
|
+
// TeX control word ends at the first NON-letter, so `\bopenout\b` misses
|
|
68
|
+
// `\openout1`, which is how anyone writes it. It must cut both ways:
|
|
69
|
+
// `\inputencoding` and `\includegraphics` are ordinary and must pass.
|
|
70
|
+
export const LATEX_FORBIDDEN = Object.freeze([
|
|
71
|
+
{ re: /\\write[\s]*18/, why: "runs a shell command" },
|
|
72
|
+
{ re: /\\(ShellEscape|directlua|luadirect|pdfprimitive|javascript)(?![A-Za-z])/, why: "runs code outside TeX" },
|
|
73
|
+
{ re: /\\(openout|openin|read|write)[a-z]*(?![A-Za-z])/, why: "reads or writes files" },
|
|
74
|
+
{ re: /\\(input|include|includeonly|subfile|subimport|import)(?![A-Za-z])/, why: "pulls in another file" },
|
|
75
|
+
{ re: /shellesc|\bshell-?escape/, why: "asks for shell escape" },
|
|
76
|
+
{ re: /\\documentclass|\\begin[\s]*\{document\}/, why: "is the document's, not the preamble's" },
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/** The first forbidden construct in any of `texts`, as a sentence, or "". */
|
|
80
|
+
export function latexForbiddenReason(texts) {
|
|
81
|
+
const all = (Array.isArray(texts) ? texts : [texts]).filter((t) => typeof t === "string");
|
|
82
|
+
for (const rule of LATEX_FORBIDDEN) {
|
|
83
|
+
if (all.some((t) => rule.re.test(t))) return rule.why;
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isScalarString = (x) => typeof x === "string";
|
|
89
|
+
const readHead = (file, lines) => {
|
|
90
|
+
let text;
|
|
91
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { return ""; }
|
|
92
|
+
return text.split("\n").slice(0, lines).join("\n");
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const CSL_NS = "http://purl.org/net/xbiblio/csl";
|
|
96
|
+
/** The repository's own name alphabet (measured over all 10,861 entries). */
|
|
97
|
+
export const CSL_NAME_RE = /^[a-z0-9][a-z0-9.-]{0,118}$/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A style's own <title>, and the parent a DEPENDENT style names.
|
|
101
|
+
*
|
|
102
|
+
* Read as text from the head of the file — a full XML parse of Chicago
|
|
103
|
+
* (164 KB) for two lines is not worth a dependency.
|
|
104
|
+
*/
|
|
105
|
+
export function cslInfo(file) {
|
|
106
|
+
const head = readHead(file, 60);
|
|
107
|
+
let title = "";
|
|
108
|
+
const m = head.match(/<title>[^<]+<\/title>/);
|
|
109
|
+
if (m) title = m[0].replace(/^<title>/, "").replace(/<\/title>$/, "").trim();
|
|
110
|
+
// The five XML entities, decoded, in the R's order: a title is shown as text,
|
|
111
|
+
// and "Taylor & Francis" is not a name.
|
|
112
|
+
for (const [from, to] of [["<", "<"], [">", ">"], [""", '"'], ["'", "'"], ["&", "&"]]) {
|
|
113
|
+
title = title.split(from).join(to);
|
|
114
|
+
}
|
|
115
|
+
let parentName = "";
|
|
116
|
+
const link = head.match(/<link[^>]*rel="independent-parent"[^>]*\/?>/);
|
|
117
|
+
if (link) {
|
|
118
|
+
const href = link[0].match(/href="[^"]+"/);
|
|
119
|
+
if (href) parentName = path.basename(href[0].replace(/^href="/, "").replace(/"$/, ""));
|
|
120
|
+
}
|
|
121
|
+
parentName = parentName.replace(/\.csl$/, "");
|
|
122
|
+
return { title, parent: parentName ? `${parentName}.csl` : "" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Is this file a CSL style? The root element, the namespace, a title. */
|
|
126
|
+
export function cslLooksLikeStyle(file) {
|
|
127
|
+
const head = readHead(file, 40);
|
|
128
|
+
return /<style[ >]/.test(head) && head.includes(CSL_NS)
|
|
129
|
+
&& !/<!DOCTYPE|<!ENTITY/.test(head) && Boolean(cslInfo(file).title);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The registry a `csl` install fetches by name from. Env-driven, as in R. */
|
|
133
|
+
export const cslIndexUrl = (env) => env("CARMAR_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json");
|
|
134
|
+
export const cslStyleBase = (env) => env("CARMAR_CSL_STYLE_BASE", "https://www.zotero.org/styles/");
|
|
135
|
+
|
|
136
|
+
/* ── the kinds table ──────────────────────────────────────────────────────
|
|
137
|
+
*
|
|
138
|
+
* Closed, by design. A row is: what the payload is, who reads it, how big it
|
|
139
|
+
* may be, how it is checked, and — for a kind with a public registry — where it
|
|
140
|
+
* is fetched by name. `validate(dir, file)` returns {ok, error, name, version,
|
|
141
|
+
* requires}; `requires` names OTHER plugins of the same kind this one renders
|
|
142
|
+
* through (a dependent CSL style's parent), which an install completes.
|
|
143
|
+
*/
|
|
144
|
+
export function pluginKinds(env) {
|
|
145
|
+
return {
|
|
146
|
+
csl: {
|
|
147
|
+
label: "Citation styles",
|
|
148
|
+
consumer: "pandoc renders citations and the reference list with it; it is placed beside a saved document.",
|
|
149
|
+
extension: "csl",
|
|
150
|
+
max_bytes: 2000000,
|
|
151
|
+
license: "CC BY-SA 3.0",
|
|
152
|
+
registry: () => ({ base: cslStyleBase(env), index: cslIndexUrl(env) }),
|
|
153
|
+
validate(dir, file) {
|
|
154
|
+
const p = path.join(dir, file);
|
|
155
|
+
if (!cslLooksLikeStyle(p)) return { ok: false, error: "did not arrive as a CSL style" };
|
|
156
|
+
const info = cslInfo(p);
|
|
157
|
+
const parent = info.parent.replace(/\.csl$/, "");
|
|
158
|
+
if (parent && !PLUGIN_ID_RE.test(parent)) {
|
|
159
|
+
return { ok: false, error: "names a parent style this kernel will not fetch" };
|
|
160
|
+
}
|
|
161
|
+
const head = readHead(p, 80);
|
|
162
|
+
const updated = head.match(/<updated>[^<]+<\/updated>/);
|
|
163
|
+
const version = updated ? updated[0].replace(/<\/?updated>/g, "").slice(0, 10) : "";
|
|
164
|
+
return { ok: true, name: info.title, version, requires: parent ? [parent] : [] };
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
theme: {
|
|
168
|
+
label: "Editor themes",
|
|
169
|
+
consumer: "the editor: chunks, console, script editor and R code in prose.",
|
|
170
|
+
extension: "json",
|
|
171
|
+
max_bytes: 65536,
|
|
172
|
+
license: "",
|
|
173
|
+
registry: null,
|
|
174
|
+
validate(dir, file) {
|
|
175
|
+
let doc;
|
|
176
|
+
try { doc = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8")); }
|
|
177
|
+
catch (e) { return { ok: false, error: `is not valid JSON: ${e.message}` }; }
|
|
178
|
+
const label = doc && doc.label;
|
|
179
|
+
if (!isScalarString(label) || !label.trim() || label.length > 60) {
|
|
180
|
+
return { ok: false, error: "needs a `label` of up to 60 characters" };
|
|
181
|
+
}
|
|
182
|
+
if (typeof doc.dark !== "boolean") return { ok: false, error: "needs `dark`: true or false" };
|
|
183
|
+
const vars = doc.vars;
|
|
184
|
+
if (!vars || typeof vars !== "object" || Array.isArray(vars) || !Object.keys(vars).length) {
|
|
185
|
+
return { ok: false, error: "needs a `vars` object of editor tokens" };
|
|
186
|
+
}
|
|
187
|
+
const unknown = Object.keys(vars).filter((k) => !THEME_TOKENS.includes(k));
|
|
188
|
+
if (unknown.length) {
|
|
189
|
+
return { ok: false, error: `sets tokens the editor does not have: ${unknown.slice(0, 3).join(", ")}` };
|
|
190
|
+
}
|
|
191
|
+
if (!["--ed-bg", "--ed-fg"].every((k) => k in vars)) {
|
|
192
|
+
return { ok: false, error: "must set at least --ed-bg and --ed-fg" };
|
|
193
|
+
}
|
|
194
|
+
const bad = Object.keys(vars).find((k) => {
|
|
195
|
+
const v = vars[k];
|
|
196
|
+
if (!isScalarString(v)) return true;
|
|
197
|
+
const hex = /^#[0-9a-fA-F]{6}$/.test(v);
|
|
198
|
+
const wash = THEME_WASH_TOKENS.includes(k)
|
|
199
|
+
&& /^rgba\(\s*[0-9]{1,3}\s*,\s*[0-9]{1,3}\s*,\s*[0-9]{1,3}\s*,\s*(0|1|0?\.[0-9]{1,4})\s*\)$/.test(v);
|
|
200
|
+
return !(hex || wash);
|
|
201
|
+
});
|
|
202
|
+
if (bad) return { ok: false, error: `colour for ${bad} must be #rrggbb (a wash may be rgba())` };
|
|
203
|
+
const version = isScalarString(doc.version) ? doc.version.slice(0, 40) : "";
|
|
204
|
+
return { ok: true, name: label.trim(), version, requires: [] };
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
latex: {
|
|
208
|
+
label: "LaTeX templates",
|
|
209
|
+
consumer: "the LaTeX export: it sets the document class, its options and the preamble of the .tex that is written.",
|
|
210
|
+
extension: "json",
|
|
211
|
+
max_bytes: 65536,
|
|
212
|
+
license: "",
|
|
213
|
+
registry: null,
|
|
214
|
+
validate(dir, file) {
|
|
215
|
+
let doc;
|
|
216
|
+
try { doc = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8")); }
|
|
217
|
+
catch (e) { return { ok: false, error: `is not valid JSON: ${e.message}` }; }
|
|
218
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc) || !Object.keys(doc).length) {
|
|
219
|
+
return { ok: false, error: "is not a template object" };
|
|
220
|
+
}
|
|
221
|
+
const unknown = Object.keys(doc).filter((k) => !LATEX_TEMPLATE_KEYS.includes(k));
|
|
222
|
+
if (unknown.length) {
|
|
223
|
+
return { ok: false, error: `sets keys a template does not have: ${unknown.slice(0, 3).join(", ")}` };
|
|
224
|
+
}
|
|
225
|
+
const one = (v, max) => isScalarString(v) && v.trim() !== "" && v.length <= max;
|
|
226
|
+
// null → "not given"; a string → one; an array of strings → itself;
|
|
227
|
+
// anything else → null, which the caller reads as "not a list of strings".
|
|
228
|
+
const strings = (v) => {
|
|
229
|
+
if (v == null) return [];
|
|
230
|
+
if (typeof v === "string") return [v];
|
|
231
|
+
if (!Array.isArray(v)) return null;
|
|
232
|
+
if (!v.length) return [];
|
|
233
|
+
if (!v.every(isScalarString)) return null;
|
|
234
|
+
return v;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (!one(doc.label, 60)) return { ok: false, error: "needs a `label` of up to 60 characters" };
|
|
238
|
+
if (!one(doc.documentclass, 40) || !LATEX_CLASS_RE.test(doc.documentclass)) {
|
|
239
|
+
return { ok: false, error: "needs a `documentclass` that is a plain class name (letters, digits, . _ -)" };
|
|
240
|
+
}
|
|
241
|
+
const opts = strings(doc.classOptions);
|
|
242
|
+
if (opts === null) return { ok: false, error: "`classOptions` must be a list of strings" };
|
|
243
|
+
if (opts.length > 12) return { ok: false, error: "names more class options than a class takes" };
|
|
244
|
+
const badOpt = opts.find((o) => !LATEX_OPTION_RE.test(o));
|
|
245
|
+
if (badOpt) return { ok: false, error: `has a class option that is not a plain option: ${badOpt}` };
|
|
246
|
+
|
|
247
|
+
const pre = strings(doc.preamble);
|
|
248
|
+
if (pre === null) return { ok: false, error: "`preamble` must be a list of lines" };
|
|
249
|
+
if (pre.length > LATEX_PREAMBLE_MAX_LINES) {
|
|
250
|
+
return { ok: false, error: `has a preamble longer than ${LATEX_PREAMBLE_MAX_LINES} lines` };
|
|
251
|
+
}
|
|
252
|
+
if (pre.some((l) => l.length > LATEX_PREAMBLE_MAX_CHARS)) {
|
|
253
|
+
return { ok: false, error: `has a preamble line longer than ${LATEX_PREAMBLE_MAX_CHARS} characters` };
|
|
254
|
+
}
|
|
255
|
+
const why = latexForbiddenReason([doc.documentclass, ...opts, ...pre, String(doc.citePackage ?? "")]);
|
|
256
|
+
if (why) return { ok: false, error: `contains LaTeX that ${why}, which a template may not do` };
|
|
257
|
+
|
|
258
|
+
if (doc.citeStyle != null && !(one(doc.citeStyle, 20) && LATEX_CITE_STYLES.includes(doc.citeStyle))) {
|
|
259
|
+
return { ok: false, error: `\`citeStyle\` must be one of ${LATEX_CITE_STYLES.join(", ")}` };
|
|
260
|
+
}
|
|
261
|
+
if (doc.citePackage != null && !(one(doc.citePackage, 200) && LATEX_CITE_PACKAGE_RE.test(doc.citePackage))) {
|
|
262
|
+
return { ok: false, error: "`citePackage` must be a single \\usepackage line" };
|
|
263
|
+
}
|
|
264
|
+
if (doc.topLevel != null && !(one(doc.topLevel, 20) && LATEX_TOP_LEVELS.includes(doc.topLevel))) {
|
|
265
|
+
return { ok: false, error: `\`topLevel\` must be one of ${LATEX_TOP_LEVELS.join(", ")}` };
|
|
266
|
+
}
|
|
267
|
+
if (doc.description != null && !one(doc.description, 200)) {
|
|
268
|
+
return { ok: false, error: "`description` must be a sentence of up to 200 characters" };
|
|
269
|
+
}
|
|
270
|
+
const version = isScalarString(doc.version) ? doc.version.slice(0, 40) : "";
|
|
271
|
+
return { ok: true, name: doc.label.trim(), version, requires: [] };
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const pluginKindOk = (kinds, kind) => isScalarString(kind) && Object.hasOwn(kinds, kind);
|
|
278
|
+
export const pluginIdOk = (id) => isScalarString(id) && PLUGIN_ID_RE.test(id);
|
|
279
|
+
export const pluginFileOk = (file) => isScalarString(file) && PLUGIN_FILE_RE.test(file) && !file.includes("..");
|
|
280
|
+
|
|
281
|
+
/** Split a policy value the way spike/settings.R's policy_split does. */
|
|
282
|
+
export const policySplit = (value) => String(value ?? "").split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
283
|
+
|
|
284
|
+
/** The host of a URL, lower-cased, or "" when it has none. */
|
|
285
|
+
export function urlHost(url) {
|
|
286
|
+
const m = String(url ?? "").match(/^https?:\/\/[^/?#]+/);
|
|
287
|
+
if (!m) return "";
|
|
288
|
+
return m[0].replace(/^https?:\/\//, "").replace(/:[0-9]+$/, "").toLowerCase();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `tools::R_user_dir(pkg, which)`, in JavaScript.
|
|
293
|
+
*
|
|
294
|
+
* The whole plugin store, the CSL index cache and every test that relocates
|
|
295
|
+
* them turn on this function agreeing with R's, so it is a transcription of
|
|
296
|
+
* R 4.5's source rather than a guess: the env var, then XDG, then the
|
|
297
|
+
* platform's own place, then `R/<package>` under it.
|
|
298
|
+
*/
|
|
299
|
+
export function rUserDir(pkg, which, env) {
|
|
300
|
+
const home = os.homedir();
|
|
301
|
+
const win = process.platform === "win32";
|
|
302
|
+
const mac = process.platform === "darwin";
|
|
303
|
+
let p = "";
|
|
304
|
+
if (which === "data") {
|
|
305
|
+
p = env("R_USER_DATA_DIR") || env("XDG_DATA_HOME")
|
|
306
|
+
|| (win ? path.join(env("APPDATA"), "R", "data")
|
|
307
|
+
: mac ? path.join(home, "Library", "Application Support", "org.R-project.R")
|
|
308
|
+
: path.join(home, ".local", "share"));
|
|
309
|
+
} else if (which === "cache") {
|
|
310
|
+
p = env("R_USER_CACHE_DIR") || env("XDG_CACHE_HOME")
|
|
311
|
+
|| (win ? path.join(env("LOCALAPPDATA"), "R", "cache")
|
|
312
|
+
: mac ? path.join(home, "Library", "Caches", "org.R-project.R")
|
|
313
|
+
: path.join(home, ".cache"));
|
|
314
|
+
} else {
|
|
315
|
+
p = env("R_USER_CONFIG_DIR") || env("XDG_CONFIG_HOME")
|
|
316
|
+
|| (win ? path.join(env("APPDATA"), "R", "config")
|
|
317
|
+
: mac ? path.join(home, "Library", "Preferences", "org.R-project.R")
|
|
318
|
+
: path.join(home, ".config"));
|
|
319
|
+
}
|
|
320
|
+
return path.join(p, "R", pkg);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ── the administrator's policy ──────────────────────────────────────────── */
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Read the administrator's plugin policy.
|
|
327
|
+
*
|
|
328
|
+
* Three states, as in ai-policy.R: names nothing → everything; names something
|
|
329
|
+
* valid → exactly that; names ONLY junk → an error, because an empty allow-list
|
|
330
|
+
* that fails open is the bug the AI policy suite caught.
|
|
331
|
+
*
|
|
332
|
+
* @returns {{set:boolean, source:string, install:string, sources:string[],
|
|
333
|
+
* network:boolean, kinds:string[], unknown:string[], note:string,
|
|
334
|
+
* system_dir:string, errors:string[]}}
|
|
335
|
+
*/
|
|
336
|
+
export function carmarPluginPolicy(env, {
|
|
337
|
+
defaultFile = PLUGIN_POLICY_DEFAULT_FILE, defaultDir = PLUGIN_SYSTEM_DEFAULT_DIR,
|
|
338
|
+
} = {}) {
|
|
339
|
+
const kinds_table = pluginKinds(env);
|
|
340
|
+
const allKinds = Object.keys(kinds_table);
|
|
341
|
+
const errors = [];
|
|
342
|
+
let source = "";
|
|
343
|
+
let install = "";
|
|
344
|
+
let sourcesRaw = null;
|
|
345
|
+
let kindsRaw = null;
|
|
346
|
+
let note = "";
|
|
347
|
+
let systemDir = env("CARMAR_PLUGIN_DIR", "").trim();
|
|
348
|
+
if (!systemDir && defaultDir && dirExists(defaultDir)) systemDir = defaultDir;
|
|
349
|
+
|
|
350
|
+
let file = env("CARMAR_PLUGIN_POLICY", "").trim();
|
|
351
|
+
if (!file && defaultFile && fileExists(defaultFile)) file = defaultFile;
|
|
352
|
+
if (file) {
|
|
353
|
+
source = file;
|
|
354
|
+
if (!fileExists(file)) {
|
|
355
|
+
errors.push(`CARMAR_PLUGIN_POLICY names a file that does not exist: ${file}`);
|
|
356
|
+
} else {
|
|
357
|
+
let doc = null;
|
|
358
|
+
try { doc = JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
359
|
+
catch (e) { errors.push(`CARMAR_PLUGIN_POLICY is not valid JSON: ${e.message}`); }
|
|
360
|
+
if (doc && typeof doc === "object") {
|
|
361
|
+
install = String(doc.install ?? "").trim();
|
|
362
|
+
if (doc.sources != null) sourcesRaw = [].concat(doc.sources).map(String);
|
|
363
|
+
if (doc.kinds != null) kindsRaw = [].concat(doc.kinds).map(String);
|
|
364
|
+
note = String(doc.note ?? "").trim();
|
|
365
|
+
const d = String(doc.system_dir ?? "").trim();
|
|
366
|
+
if (d) systemDir = d;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
if (env("CARMAR_PLUGIN_INSTALL", "")) { source = "env"; install = env("CARMAR_PLUGIN_INSTALL", "").trim(); }
|
|
371
|
+
if (env("CARMAR_PLUGIN_SOURCES", "")) { source = "env"; sourcesRaw = policySplit(env("CARMAR_PLUGIN_SOURCES", "")); }
|
|
372
|
+
if (env("CARMAR_PLUGIN_KINDS", "")) { source = "env"; kindsRaw = policySplit(env("CARMAR_PLUGIN_KINDS", "")); }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (!install) install = "user";
|
|
376
|
+
if (!["user", "none"].includes(install)) {
|
|
377
|
+
errors.push(`The plugin policy's \`install\` must be "user" or "none", not "${install}".`);
|
|
378
|
+
install = "user";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Kinds: three states.
|
|
382
|
+
const kindsNamed = [...new Set((kindsRaw || []).map((k) => k.trim()).filter(Boolean))];
|
|
383
|
+
const unknown = kindsNamed.filter((k) => !allKinds.includes(k));
|
|
384
|
+
let kinds = kindsNamed.length ? kindsNamed.filter((k) => allKinds.includes(k)) : allKinds.slice();
|
|
385
|
+
if (kindsNamed.length && !kinds.length) {
|
|
386
|
+
errors.push(`The plugin policy permits no plugin kind at all (unrecognised: ${unknown.join(", ")}). `
|
|
387
|
+
+ `Name at least one of ${allKinds.join(", ")}, or remove the policy.`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Sources: absent → the kinds' own registries; "none" → no network at all;
|
|
391
|
+
// else an exact host list. https is required of every fetch regardless.
|
|
392
|
+
let network = true;
|
|
393
|
+
let sources = [];
|
|
394
|
+
if (sourcesRaw == null) {
|
|
395
|
+
sources = pluginDefaultHosts(env);
|
|
396
|
+
} else {
|
|
397
|
+
const named = [...new Set(sourcesRaw.map((s) => s.trim().toLowerCase()).filter(Boolean))];
|
|
398
|
+
if ((named.length === 1 && named[0] === "none") || !named.length) {
|
|
399
|
+
network = false;
|
|
400
|
+
} else {
|
|
401
|
+
const bad = named.filter((h) => !/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(h));
|
|
402
|
+
if (bad.length) errors.push(`The plugin policy's \`sources\` must be bare host names: ${bad.join(", ")}`);
|
|
403
|
+
sources = named.filter((h) => !bad.includes(h));
|
|
404
|
+
if (!sources.length) network = false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let set = Boolean(source) && !errors.length;
|
|
409
|
+
if (errors.length) { install = "user"; kinds = allKinds.slice(); sources = pluginDefaultHosts(env); network = true; set = false; }
|
|
410
|
+
// eslint-disable-next-line no-control-regex
|
|
411
|
+
note = note.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 400).trim();
|
|
412
|
+
|
|
413
|
+
return { set, source, install, sources, network, kinds, unknown, note, system_dir: systemDir, errors };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* The hosts an install may fetch from when no policy names any: the registries
|
|
418
|
+
* of the kinds themselves. Computed, not written down, so a test pointing the
|
|
419
|
+
* CSL registry at 127.0.0.1 permits 127.0.0.1 without a second override.
|
|
420
|
+
*/
|
|
421
|
+
export function pluginDefaultHosts(env) {
|
|
422
|
+
const table = pluginKinds(env);
|
|
423
|
+
const hosts = [];
|
|
424
|
+
for (const k of Object.values(table)) {
|
|
425
|
+
if (!k.registry) continue;
|
|
426
|
+
const r = k.registry();
|
|
427
|
+
hosts.push(urlHost(r.base), urlHost(r.index));
|
|
428
|
+
}
|
|
429
|
+
return [...new Set(hosts.filter(Boolean))];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** The sentence shown when the policy refuses something. */
|
|
433
|
+
export function pluginPolicyReason(policy, what = "install") {
|
|
434
|
+
const base = {
|
|
435
|
+
install: "This CarmaR is configured so that plugins are not installed by users; the set is fixed by the deployment.",
|
|
436
|
+
network: "This CarmaR is configured to fetch no plugins from the network; a plugin arrives as a file.",
|
|
437
|
+
host: `This CarmaR is configured to fetch plugins only from: ${policy.sources.join(", ")}.`,
|
|
438
|
+
kind: "This CarmaR is configured without that plugin kind.",
|
|
439
|
+
}[what] || "This CarmaR's plugin policy refuses that.";
|
|
440
|
+
return policy.note ? `${base} ${policy.note}` : base;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* ── layers and paths ────────────────────────────────────────────────────── */
|
|
444
|
+
|
|
445
|
+
const dirExists = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
446
|
+
const fileExists = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
|
|
447
|
+
|
|
448
|
+
export const pluginUserDir = (env) => path.join(rUserDir(appId(env), "data", env), "plugins");
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The layers, in precedence order. The user layer is ABSENT under
|
|
452
|
+
* `install: none` — not read-only, absent, because "the set is fixed by the
|
|
453
|
+
* deployment" and a person's earlier imports quietly outranking it would make
|
|
454
|
+
* that sentence false.
|
|
455
|
+
*/
|
|
456
|
+
export function pluginLayers(here, policy, env) {
|
|
457
|
+
const layers = [];
|
|
458
|
+
if (policy.install === "user") layers.push({ name: "user", dir: pluginUserDir(env), writable: true });
|
|
459
|
+
if (policy.system_dir) layers.push({ name: "system", dir: policy.system_dir, writable: false });
|
|
460
|
+
layers.push({ name: "shipped", dir: path.join(here, "plugins"), writable: false });
|
|
461
|
+
return layers;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* A path INSIDE `base`, or null. The regexes on id and file already forbid a
|
|
466
|
+
* separator and `..`; this is the SECOND lock — resolved paths, compared — so a
|
|
467
|
+
* symlink or an encoding trick a regex did not foresee still cannot reach out
|
|
468
|
+
* of the layer.
|
|
469
|
+
*/
|
|
470
|
+
export function pluginContained(base, ...parts) {
|
|
471
|
+
if (!dirExists(base)) return null;
|
|
472
|
+
let root;
|
|
473
|
+
try { root = fs.realpathSync(base); } catch { return null; }
|
|
474
|
+
if (!parts.length) return null;
|
|
475
|
+
if (!parts.every((x) => isScalarString(x) && !/[/\\]/.test(x) && !/^\.\.?$/.test(x))) return null;
|
|
476
|
+
let p = path.join(root, parts.join("/"));
|
|
477
|
+
// An existing target is resolved through any symlink before the check; one
|
|
478
|
+
// that does not exist yet is a plain child of the resolved root by
|
|
479
|
+
// construction (no separator, no dot segment — refused above).
|
|
480
|
+
if (fs.existsSync(p)) { try { p = fs.realpathSync(p); } catch { return null; } }
|
|
481
|
+
if (p !== root && !p.startsWith(`${root}${path.sep}`)) return null;
|
|
482
|
+
return p;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function pluginDigest(file) {
|
|
486
|
+
const hash = crypto.createHash("sha256");
|
|
487
|
+
hash.update(fs.readFileSync(file));
|
|
488
|
+
return { algo: "sha256", value: hash.digest("hex") };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/* ── the manifest ────────────────────────────────────────────────────────── */
|
|
492
|
+
|
|
493
|
+
/** Build the manifest for a validated payload sitting in `dir`. */
|
|
494
|
+
export function pluginManifestFor(dir, kind, id, files, info, source, license = "") {
|
|
495
|
+
const list = [].concat(files);
|
|
496
|
+
const sha256 = {};
|
|
497
|
+
for (const f of list) sha256[f] = pluginDigest(path.join(dir, f)).value;
|
|
498
|
+
return {
|
|
499
|
+
id, kind,
|
|
500
|
+
name: String(info.name ?? id).slice(0, 200),
|
|
501
|
+
version: info.version ?? "",
|
|
502
|
+
file: list[0],
|
|
503
|
+
files: list,
|
|
504
|
+
requires: [].concat(info.requires ?? []).map(String),
|
|
505
|
+
license: license ?? "",
|
|
506
|
+
source,
|
|
507
|
+
digest: "sha256",
|
|
508
|
+
sha256,
|
|
509
|
+
bytes: list.reduce((n, f) => n + fs.statSync(path.join(dir, f)).size, 0),
|
|
510
|
+
installed: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export function pluginManifestWrite(dir, manifest) {
|
|
515
|
+
fs.writeFileSync(path.join(dir, PLUGIN_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Read and CHECK one plugin folder.
|
|
520
|
+
*
|
|
521
|
+
* Returns a row with `status` "ok" or "broken" and a `reason`; a folder is
|
|
522
|
+
* never trusted for what its manifest claims.
|
|
523
|
+
*/
|
|
524
|
+
export function pluginInspect(layer, kind, id, kindsTable) {
|
|
525
|
+
const dir = pluginContained(path.join(layer.dir, kind), id);
|
|
526
|
+
const row = {
|
|
527
|
+
kind, id, name: id, version: "", layer: layer.name, dir: dir || "",
|
|
528
|
+
file: "", files: [], requires: [], source: "", license: "",
|
|
529
|
+
installed: "", bytes: 0, status: "broken", reason: "", verified: false,
|
|
530
|
+
};
|
|
531
|
+
if (!dir || !dirExists(dir)) { row.reason = "the folder is outside its layer"; return row; }
|
|
532
|
+
const mpath = path.join(dir, PLUGIN_MANIFEST);
|
|
533
|
+
if (!fileExists(mpath)) { row.reason = "no plugin.json"; return row; }
|
|
534
|
+
if (fs.statSync(mpath).size > PLUGIN_MANIFEST_MAX_BYTES) { row.reason = "plugin.json is too large to be one"; return row; }
|
|
535
|
+
let m;
|
|
536
|
+
try { m = JSON.parse(fs.readFileSync(mpath, "utf8")); }
|
|
537
|
+
catch (e) { row.reason = `plugin.json is not valid JSON: ${e.message}`; return row; }
|
|
538
|
+
if (m.id !== id) { row.reason = "plugin.json names a different id than its folder"; return row; }
|
|
539
|
+
if (m.kind !== kind) { row.reason = "plugin.json names a different kind than its folder"; return row; }
|
|
540
|
+
const files = [].concat(m.files ?? m.file ?? []).map((f) => (typeof f === "string" ? f : ""));
|
|
541
|
+
if (!files.length || !files.every(pluginFileOk)) { row.reason = "plugin.json lists no valid file"; return row; }
|
|
542
|
+
const spec = kindsTable[kind];
|
|
543
|
+
if (!files[0].endsWith(`.${spec.extension}`)) { row.reason = `the payload is not a .${spec.extension} file`; return row; }
|
|
544
|
+
row.name = String(m.name ?? id).slice(0, 200);
|
|
545
|
+
row.version = String(m.version ?? "");
|
|
546
|
+
row.file = files[0];
|
|
547
|
+
row.files = files;
|
|
548
|
+
row.requires = [].concat(m.requires ?? []).map(String);
|
|
549
|
+
row.source = String(m.source ?? "");
|
|
550
|
+
row.license = String(m.license ?? "");
|
|
551
|
+
row.installed = String(m.installed ?? "");
|
|
552
|
+
for (const f of files) {
|
|
553
|
+
const p = pluginContained(dir, f);
|
|
554
|
+
if (!p || !fs.existsSync(p)) { row.reason = `${f} is missing`; return row; }
|
|
555
|
+
const size = fs.statSync(p).size;
|
|
556
|
+
if (size > spec.max_bytes) { row.reason = `${f} is larger than this kind allows`; return row; }
|
|
557
|
+
row.bytes += size;
|
|
558
|
+
}
|
|
559
|
+
// Integrity: the recorded digest, recomputed.
|
|
560
|
+
const algo = String(m.digest ?? (m.sha256 ? "sha256" : m.md5 ? "md5" : ""));
|
|
561
|
+
const recorded = algo === "sha256" ? m.sha256 : algo === "md5" ? m.md5 : null;
|
|
562
|
+
const can = algo === "sha256"; // node has no md5 manifest to answer for
|
|
563
|
+
if (algo && recorded && can) {
|
|
564
|
+
for (const f of files) {
|
|
565
|
+
const want = String(recorded[f] ?? "");
|
|
566
|
+
const have = pluginDigest(path.join(dir, f)).value;
|
|
567
|
+
if (!want || want.toLowerCase() !== have.toLowerCase()) {
|
|
568
|
+
row.reason = `${f} has changed since it was installed (${algo} differs)`;
|
|
569
|
+
return row;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
row.verified = true;
|
|
573
|
+
}
|
|
574
|
+
row.status = "ok";
|
|
575
|
+
return row;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Every plugin in every layer, checked, one row each.
|
|
580
|
+
*
|
|
581
|
+
* `active` marks the copy consumers use: the FIRST ok copy of an id in layer
|
|
582
|
+
* order. Kinds the policy does not permit are not listed at all.
|
|
583
|
+
*/
|
|
584
|
+
export function pluginRows(here, policy, env, kind = null) {
|
|
585
|
+
const table = pluginKinds(env);
|
|
586
|
+
const kinds = kind == null ? policy.kinds : [].concat(kind).filter((k) => policy.kinds.includes(k));
|
|
587
|
+
const rows = [];
|
|
588
|
+
for (const layer of pluginLayers(here, policy, env)) {
|
|
589
|
+
for (const k of kinds) {
|
|
590
|
+
const kdir = path.join(layer.dir, k);
|
|
591
|
+
if (!dirExists(kdir)) continue;
|
|
592
|
+
let ids;
|
|
593
|
+
try { ids = fs.readdirSync(kdir).sort(); } catch { continue; }
|
|
594
|
+
ids = ids.filter((id) => !id.startsWith(".") && dirExists(path.join(kdir, id)) && PLUGIN_ID_RE.test(id));
|
|
595
|
+
for (const id of ids) rows.push(pluginInspect(layer, k, id, table));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const claimed = new Set();
|
|
599
|
+
for (const r of rows) {
|
|
600
|
+
const key = `${r.kind} ${r.id}`;
|
|
601
|
+
r.active = r.status === "ok" && !claimed.has(key);
|
|
602
|
+
if (r.active) claimed.add(key);
|
|
603
|
+
}
|
|
604
|
+
return rows;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** The active copy of one plugin, or null. */
|
|
608
|
+
export function pluginActive(here, policy, env, kind, id) {
|
|
609
|
+
const table = pluginKinds(env);
|
|
610
|
+
if (!pluginKindOk(table, kind) || !pluginIdOk(id)) return null;
|
|
611
|
+
return pluginRows(here, policy, env, kind).find((r) => r.id === id && r.active) || null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** The text of one manifest-listed file of an active plugin. */
|
|
615
|
+
export function pluginRead(here, policy, env, kind, id, file = null) {
|
|
616
|
+
const row = pluginActive(here, policy, env, kind, id);
|
|
617
|
+
if (!row) return { ok: false, error: "No such plugin." };
|
|
618
|
+
const want = file ?? row.file;
|
|
619
|
+
if (!pluginFileOk(want) || !row.files.includes(want)) return { ok: false, error: "That file is not part of the plugin." };
|
|
620
|
+
const p = pluginContained(row.dir, want);
|
|
621
|
+
if (!p) return { ok: false, error: "That file is not inside the plugin." };
|
|
622
|
+
let text;
|
|
623
|
+
try { text = fs.readFileSync(p, "utf8").replace(/\n$/, ""); } catch { return { ok: false, error: "The file could not be read." }; }
|
|
624
|
+
return { ok: true, kind, id, file: want, name: row.name, text };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/* ── fetching ────────────────────────────────────────────────────────────── */
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Fetch one URL into `dest`, bounded, from a permitted host only.
|
|
631
|
+
* https everywhere but loopback (the test registry); the host must be on the
|
|
632
|
+
* policy's list. Resolves to "" or the reason.
|
|
633
|
+
*/
|
|
634
|
+
export async function pluginFetch(policy, url, dest, maxBytes) {
|
|
635
|
+
if (policy.network !== true) return pluginPolicyReason(policy, "network");
|
|
636
|
+
if (!isScalarString(url)) return "not a URL";
|
|
637
|
+
const host = urlHost(url);
|
|
638
|
+
const loopback = ["127.0.0.1", "localhost"].includes(host);
|
|
639
|
+
if (!url.startsWith("https://") && !(loopback && url.startsWith("http://"))) return "only https is fetched";
|
|
640
|
+
if (!host || !policy.sources.includes(host)) return pluginPolicyReason(policy, "host");
|
|
641
|
+
const controller = new AbortController();
|
|
642
|
+
const timer = setTimeout(() => controller.abort(), PLUGIN_FETCH_TIMEOUT_S * 1000);
|
|
643
|
+
try {
|
|
644
|
+
const res = await fetch(url, { signal: controller.signal, redirect: "follow" });
|
|
645
|
+
if (!res.ok) return `cannot open URL '${url}': HTTP status was ${res.status}`;
|
|
646
|
+
const body = Buffer.from(await res.arrayBuffer());
|
|
647
|
+
if (body.length > maxBytes) {
|
|
648
|
+
return `the file is larger than this kind allows (${Math.trunc(maxBytes / 1000)} KB)`;
|
|
649
|
+
}
|
|
650
|
+
fs.writeFileSync(dest, body);
|
|
651
|
+
return "";
|
|
652
|
+
} catch (e) {
|
|
653
|
+
try { fs.unlinkSync(dest); } catch { /* never written */ }
|
|
654
|
+
return e.name === "AbortError" ? "the download timed out" : e.message;
|
|
655
|
+
} finally {
|
|
656
|
+
clearTimeout(timer);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/* ── install and remove ──────────────────────────────────────────────────── */
|
|
661
|
+
|
|
662
|
+
function pluginStageDir(userDir) {
|
|
663
|
+
const staging = path.join(userDir, ".staging");
|
|
664
|
+
fs.mkdirSync(staging, { recursive: true, mode: 0o700 });
|
|
665
|
+
const d = fs.mkdtempSync(path.join(staging, "plugin-"));
|
|
666
|
+
fs.chmodSync(d, 0o700);
|
|
667
|
+
return d;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Install one plugin into the user layer.
|
|
672
|
+
*
|
|
673
|
+
* @param {object} from one of {registry} · {url} · {text, filename}
|
|
674
|
+
* @param {boolean} complete install the plugins this one `requires` from the
|
|
675
|
+
* registry (a dependent CSL style's parent). One level: a parent is
|
|
676
|
+
* independent.
|
|
677
|
+
*/
|
|
678
|
+
export async function pluginInstall(here, policy, env, kind, from, complete = true) {
|
|
679
|
+
const table = pluginKinds(env);
|
|
680
|
+
if (!pluginKindOk(table, kind)) return { ok: false, error: "That is not a plugin kind this kernel has." };
|
|
681
|
+
if (!policy.kinds.includes(kind)) return { ok: false, error: pluginPolicyReason(policy, "kind") };
|
|
682
|
+
if (policy.install !== "user") return { ok: false, error: pluginPolicyReason(policy, "install") };
|
|
683
|
+
if (!from || typeof from !== "object" || Array.isArray(from)) return { ok: false, error: "`from` must say where the plugin comes from." };
|
|
684
|
+
const spec = table[kind];
|
|
685
|
+
const ext = `.${spec.extension}`;
|
|
686
|
+
|
|
687
|
+
let id;
|
|
688
|
+
let source;
|
|
689
|
+
let door;
|
|
690
|
+
if (isScalarString(from.registry)) {
|
|
691
|
+
if (!spec.registry) return { ok: false, error: "That plugin kind has no registry to fetch by name from." };
|
|
692
|
+
id = from.registry;
|
|
693
|
+
if (!pluginIdOk(id)) return { ok: false, error: "That is not a name from the registry." };
|
|
694
|
+
source = `${spec.registry().base}${id}`;
|
|
695
|
+
door = "registry";
|
|
696
|
+
} else if (isScalarString(from.url)) {
|
|
697
|
+
source = from.url;
|
|
698
|
+
id = path.basename(source.replace(/[?#].*$/, "")).replace(new RegExp(`\\${ext}$`), "");
|
|
699
|
+
if (!pluginIdOk(id)) return { ok: false, error: "The URL does not end in a name a plugin can have (lower-case letters, digits, . _ -)." };
|
|
700
|
+
door = "url";
|
|
701
|
+
} else if (isScalarString(from.text) && isScalarString(from.filename)) {
|
|
702
|
+
const fname = from.filename; // a picked file's own name: a path in it is not a name
|
|
703
|
+
if (!pluginFileOk(fname) || !new RegExp(`\\${ext}$`, "i").test(fname)) {
|
|
704
|
+
return { ok: false, error: `The file must be a ${ext} file with a plain name.` };
|
|
705
|
+
}
|
|
706
|
+
id = fname.replace(new RegExp(`\\${ext}$`, "i"), "").toLowerCase();
|
|
707
|
+
if (!pluginIdOk(id)) return { ok: false, error: "The file's name is not one a plugin can have (lower-case letters, digits, . _ -)." };
|
|
708
|
+
if (Buffer.byteLength(from.text, "utf8") > spec.max_bytes) return { ok: false, error: "The file is larger than this kind allows." };
|
|
709
|
+
source = `file:${fname}`;
|
|
710
|
+
door = "text";
|
|
711
|
+
} else {
|
|
712
|
+
return { ok: false, error: "`from` must be a registry name, a URL, or a file's text." };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// A shipped or system copy of that id IS the plugin: nothing to write, and
|
|
716
|
+
// the answer says so. (A user copy is replaced — that is how a style updates.)
|
|
717
|
+
const have = pluginRows(here, policy, env, kind).find((r) => r.id === id && r.active && r.layer !== "user");
|
|
718
|
+
if (have) {
|
|
719
|
+
return { ok: true, kind, id, name: have.name, version: have.version, file: have.file,
|
|
720
|
+
requires: have.requires, layer: have.layer, already: true };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const userDir = pluginUserDir(env);
|
|
724
|
+
fs.mkdirSync(path.join(userDir, kind), { recursive: true, mode: 0o700 });
|
|
725
|
+
const stage = pluginStageDir(userDir);
|
|
726
|
+
let landed = false;
|
|
727
|
+
try {
|
|
728
|
+
const file = `${id}${ext}`;
|
|
729
|
+
const targetTmp = path.join(stage, file);
|
|
730
|
+
if (door === "text") {
|
|
731
|
+
try { fs.writeFileSync(targetTmp, from.text); } catch { return { ok: false, error: "Could not write the file." }; }
|
|
732
|
+
} else {
|
|
733
|
+
const err = await pluginFetch(policy, source, targetTmp, spec.max_bytes);
|
|
734
|
+
if (err) return { ok: false, error: `Could not fetch ${id}: ${err}` };
|
|
735
|
+
}
|
|
736
|
+
fs.chmodSync(targetTmp, 0o600);
|
|
737
|
+
const v = spec.validate(stage, file);
|
|
738
|
+
if (v.ok !== true) return { ok: false, error: `${id} ${v.error}.` };
|
|
739
|
+
|
|
740
|
+
// What it renders through, completed from the registry — once, BEFORE this
|
|
741
|
+
// one lands, so a plugin never sits installed with a missing parent.
|
|
742
|
+
const requires = [].concat(v.requires ?? []).map(String);
|
|
743
|
+
for (const req of requires) {
|
|
744
|
+
if (pluginActive(here, policy, env, kind, req)) continue;
|
|
745
|
+
if (complete !== true || !spec.registry) {
|
|
746
|
+
return { ok: false, error: `${id} renders through ${req}, which is not on this machine.` };
|
|
747
|
+
}
|
|
748
|
+
// eslint-disable-next-line no-await-in-loop
|
|
749
|
+
const r = await pluginInstall(here, policy, env, kind, { registry: req }, false);
|
|
750
|
+
if (r.ok !== true) return { ok: false, error: `Could not complete ${id}: ${r.error}` };
|
|
751
|
+
if (r.requires && r.requires.length) {
|
|
752
|
+
pluginRemove(here, policy, env, kind, req);
|
|
753
|
+
return { ok: false, error: `${req} is not an independent plugin; ${id} was not installed.` };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const manifest = pluginManifestFor(stage, kind, id, file, v, source, spec.license);
|
|
758
|
+
pluginManifestWrite(stage, manifest);
|
|
759
|
+
fs.chmodSync(path.join(stage, PLUGIN_MANIFEST), 0o600);
|
|
760
|
+
|
|
761
|
+
// Into place, atomically: the staged folder is renamed onto the id. An
|
|
762
|
+
// existing user copy is moved aside first and removed after, so at no
|
|
763
|
+
// instant is there half a plugin at that path.
|
|
764
|
+
const dest = pluginContained(path.join(userDir, kind), id);
|
|
765
|
+
if (!dest) return { ok: false, error: "The plugin's folder would fall outside the user layer." };
|
|
766
|
+
let old = null;
|
|
767
|
+
if (dirExists(dest)) {
|
|
768
|
+
old = path.join(userDir, ".staging", `plugin-old-${crypto.randomBytes(6).toString("hex")}`);
|
|
769
|
+
try { fs.renameSync(dest, old); } catch { return { ok: false, error: "Could not replace the existing copy." }; }
|
|
770
|
+
}
|
|
771
|
+
try { fs.renameSync(stage, dest); }
|
|
772
|
+
catch {
|
|
773
|
+
if (old) { try { fs.renameSync(old, dest); } catch { /* the copy is lost either way */ } }
|
|
774
|
+
return { ok: false, error: "Could not move the plugin into place." };
|
|
775
|
+
}
|
|
776
|
+
landed = true;
|
|
777
|
+
if (old) fs.rmSync(old, { recursive: true, force: true });
|
|
778
|
+
return { ok: true, kind, id, name: manifest.name, version: manifest.version,
|
|
779
|
+
file, requires, layer: "user", already: false };
|
|
780
|
+
} finally {
|
|
781
|
+
if (!landed) fs.rmSync(stage, { recursive: true, force: true });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Remove one plugin from the user layer. A shipped or system plugin is not the
|
|
787
|
+
* person's to remove; a plugin another user plugin renders through stays until
|
|
788
|
+
* that one goes.
|
|
789
|
+
*/
|
|
790
|
+
export function pluginRemove(here, policy, env, kind, id) {
|
|
791
|
+
const table = pluginKinds(env);
|
|
792
|
+
if (!pluginKindOk(table, kind) || !pluginIdOk(id)) return { ok: false, error: "That is not a plugin." };
|
|
793
|
+
if (policy.install !== "user") return { ok: false, error: pluginPolicyReason(policy, "install") };
|
|
794
|
+
const rows = pluginRows(here, policy, env, kind);
|
|
795
|
+
const mine = rows.find((r) => r.id === id && r.layer === "user");
|
|
796
|
+
if (!mine) {
|
|
797
|
+
if (rows.some((r) => r.id === id)) return { ok: false, error: "That plugin is part of this deployment; it cannot be removed here." };
|
|
798
|
+
return { ok: false, error: "No such plugin." };
|
|
799
|
+
}
|
|
800
|
+
const needs = rows.find((r) => r.layer === "user" && r.id !== id && r.requires.includes(id));
|
|
801
|
+
if (needs) return { ok: false, error: `${needs.name} renders through it; remove that one first.` };
|
|
802
|
+
const dest = pluginContained(path.join(pluginUserDir(env), kind), id);
|
|
803
|
+
if (!dest) return { ok: false, error: "That plugin's folder is outside the user layer." };
|
|
804
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
805
|
+
return { ok: true, kind, id };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** What the policy and the layers are, for the page: disclosure, never hidden. */
|
|
809
|
+
export function pluginPolicyDisclosure(here, policy, env) {
|
|
810
|
+
const table = pluginKinds(env);
|
|
811
|
+
const layers = pluginLayers(here, policy, env);
|
|
812
|
+
const kindInfo = {};
|
|
813
|
+
for (const k of policy.kinds) {
|
|
814
|
+
kindInfo[k] = {
|
|
815
|
+
kind: k, label: table[k].label, consumer: table[k].consumer,
|
|
816
|
+
extension: table[k].extension, max_bytes: table[k].max_bytes,
|
|
817
|
+
registry: Boolean(table[k].registry),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
set: policy.set === true,
|
|
822
|
+
source: policy.source === "env" ? "environment" : policy.source ? "file" : "",
|
|
823
|
+
install: policy.install,
|
|
824
|
+
network: policy.network === true,
|
|
825
|
+
sources: policy.sources.slice(),
|
|
826
|
+
kinds: policy.kinds.slice(),
|
|
827
|
+
note: policy.note,
|
|
828
|
+
layers: layers.map((l) => ({ name: l.name, dir: l.dir, writable: l.writable === true, exists: dirExists(l.dir) })),
|
|
829
|
+
kind_info: kindInfo,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** ONE policy per host process, as serve.R has ONE `plugin_policy`. */
|
|
834
|
+
let cachedPolicy = null;
|
|
835
|
+
export function pluginPolicyOnce(env, opts) {
|
|
836
|
+
if (!cachedPolicy) cachedPolicy = carmarPluginPolicy(env, opts);
|
|
837
|
+
return cachedPolicy;
|
|
838
|
+
}
|