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,809 @@
|
|
|
1
|
+
# plugins.R — the plugin store: things the notebook can import, and the one
|
|
2
|
+
# door they come through.
|
|
3
|
+
#
|
|
4
|
+
# ── what a plugin is ────────────────────────────────────────────────────────
|
|
5
|
+
#
|
|
6
|
+
# A PLUGIN IS DATA A KNOWN CONSUMER READS. IT IS NEVER CODE. That sentence is
|
|
7
|
+
# the whole trust model, and it is what lets the mechanism be this small. A
|
|
8
|
+
# citation style is XML that pandoc parses; an editor theme is a palette the
|
|
9
|
+
# stylesheet applies. Neither can call anything. The notebook page runs under
|
|
10
|
+
# a CSP whose only script is its own bundle; a plugin kind whose payload was
|
|
11
|
+
# JavaScript would need a second script origin and would be the Chrome
|
|
12
|
+
# extension hole in a new coat (deleted in 0.38.0; CLAUDE.md "One transport").
|
|
13
|
+
# So the table of kinds is CLOSED — each kind names its consumer, its file
|
|
14
|
+
# types, its size cap and its validator — and "js" is not a row. A kind that
|
|
15
|
+
# is not in the table cannot be installed, whatever a manifest claims.
|
|
16
|
+
#
|
|
17
|
+
# ── where plugins live: three layers, like .libPaths() ──────────────────────
|
|
18
|
+
#
|
|
19
|
+
# user R_user_dir("carmar","data")/plugins/<kind>/<id>/ the person's own; the ONLY layer written
|
|
20
|
+
# system $CARMAR_PLUGIN_DIR/<kind>/<id>/ an administrator's set, read-only
|
|
21
|
+
# shipped <kernel>/plugins/<kind>/<id>/ what the kernel came with, read-only
|
|
22
|
+
#
|
|
23
|
+
# Searched in that order; the first OK copy of an id wins ("active"), so a
|
|
24
|
+
# person's copy shadows the department's, which shadows the bundle — exactly
|
|
25
|
+
# as a user library shadows the site library in R. A BROKEN copy never wins:
|
|
26
|
+
# integrity is checked on every listing, and a plugin whose payload no longer
|
|
27
|
+
# matches its manifest is listed with the reason and used by nothing.
|
|
28
|
+
#
|
|
29
|
+
# One plugin is one folder: a manifest and its payload. The manifest is
|
|
30
|
+
# written by the kernel at install (or by tools/plugins/stamp.mjs for the
|
|
31
|
+
# shipped set) and READ BACK, never trusted: id must equal the folder name,
|
|
32
|
+
# kind must equal the folder above, every file must be a bare name inside the
|
|
33
|
+
# folder, exist, sit under the size cap and hash to what the manifest says.
|
|
34
|
+
#
|
|
35
|
+
# ── the one door, and who may open it ───────────────────────────────────────
|
|
36
|
+
#
|
|
37
|
+
# `plugin_install` is the only write. Three sources: a kind's REGISTRY (the
|
|
38
|
+
# CSL repository, by name), a URL on a permitted host, or the TEXT of a file
|
|
39
|
+
# the person picked in the page (the offline door — nothing here needs a
|
|
40
|
+
# native chooser, and no path travels on the wire). Every install stages in a
|
|
41
|
+
# temp folder, validates there, and is renamed into place, so a half-download
|
|
42
|
+
# or a file that fails its kind's check is never a plugin. serve.R makes
|
|
43
|
+
# install/remove page-only in BOTH senses (PAGE_ONLY_CLASSES + AGENT_REFUSED)
|
|
44
|
+
# and audits each: changing what a machine renders with is the person's
|
|
45
|
+
# decision, made visibly. Listing and reading are open to any socket — an
|
|
46
|
+
# agent that knows which styles exist writes `csl: apa.csl` instead of
|
|
47
|
+
# guessing.
|
|
48
|
+
#
|
|
49
|
+
# ── the administrator's policy ───────────────────────────────────────────────
|
|
50
|
+
#
|
|
51
|
+
# CARMAR_PLUGIN_POLICY=/etc/carmar/plugin-policy.json the file (wins where both are set)
|
|
52
|
+
# CARMAR_PLUGIN_DIR=/opt/carmar/plugins the system layer
|
|
53
|
+
# CARMAR_PLUGIN_INSTALL=user|none may the person install? (default user)
|
|
54
|
+
# CARMAR_PLUGIN_SOURCES=host,host|none hosts a URL/registry install may fetch from
|
|
55
|
+
# CARMAR_PLUGIN_KINDS=csl,theme which kinds exist at all
|
|
56
|
+
#
|
|
57
|
+
# Same doctrine as ai-policy.R: the honest scope is the feature. The kernel
|
|
58
|
+
# owns every fetch and every write here, so unlike an AI policy this one is
|
|
59
|
+
# ENFORCEMENT — a host not on the list is never contacted, `install: none`
|
|
60
|
+
# means the user layer is neither written nor read. And the three-state rule
|
|
61
|
+
# holds: a policy that names nothing permits everything; one that names only
|
|
62
|
+
# junk is a startup error, never a silent "all". `carmar_plugin_policy()` is
|
|
63
|
+
# pure; serve.R joins its `errors` to the startup gate.
|
|
64
|
+
#
|
|
65
|
+
# Nothing here runs at start. The kernel lists folders when asked; nothing is
|
|
66
|
+
# read until a consumer asks; the network is touched only inside an install.
|
|
67
|
+
|
|
68
|
+
`%||%` <- if (exists("%||%")) `%||%` else function(a, b) if (is.null(a)) b else a
|
|
69
|
+
|
|
70
|
+
PLUGIN_ID_RE <- "^[a-z0-9][a-z0-9._-]{0,63}$"
|
|
71
|
+
PLUGIN_FILE_RE <- "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
|
72
|
+
PLUGIN_MANIFEST <- "plugin.json"
|
|
73
|
+
PLUGIN_MANIFEST_MAX_BYTES <- 65536L
|
|
74
|
+
PLUGIN_FETCH_TIMEOUT_S <- 20L
|
|
75
|
+
PLUGIN_POLICY_DEFAULT_FILE <- "/etc/carmar/plugin-policy.json"
|
|
76
|
+
PLUGIN_SYSTEM_DEFAULT_DIR <- "/etc/carmar/plugins"
|
|
77
|
+
|
|
78
|
+
# THIS LIST IS A CROSS-LANGUAGE CONTRACT with `EDITOR_TOKENS` in
|
|
79
|
+
# lib/editor-themes.js; test/plugins.test.mjs reads both and fails if they
|
|
80
|
+
# diverge. A theme plugin may set exactly these.
|
|
81
|
+
THEME_TOKENS <- c(
|
|
82
|
+
"--ed-bg", "--ed-fg", "--ed-gutter", "--ed-gutter-bg", "--ed-line", "--ed-sel",
|
|
83
|
+
"--ed-caret", "--ed-border", "--ed-bar", "--ed-brmatch",
|
|
84
|
+
"--rtok-com", "--rtok-doc", "--rtok-section", "--rtok-tag", "--rtok-str", "--rtok-num",
|
|
85
|
+
"--rtok-kw", "--rtok-const", "--rtok-fn", "--rtok-ns", "--rtok-arg", "--rtok-op",
|
|
86
|
+
"--rtok-infix", "--rtok-paren")
|
|
87
|
+
THEME_WASH_TOKENS <- c("--ed-line", "--ed-sel", "--ed-brmatch")
|
|
88
|
+
|
|
89
|
+
# ── the latex kind's vocabulary ─────────────────────────────────────────────
|
|
90
|
+
#
|
|
91
|
+
# THE SECOND CROSS-LANGUAGE CONTRACT, with `TEMPLATE_KEYS` / `CITE_STYLES` /
|
|
92
|
+
# `TOP_LEVELS` in lib/latex-templates.js; test/plugins.test.mjs reads both.
|
|
93
|
+
#
|
|
94
|
+
# A template plugin may set exactly these keys. It carries no `.cls` — a
|
|
95
|
+
# journal class is on the author's TeX installation or it is not, and shipping
|
|
96
|
+
# someone's class file would be redistribution with a licence question
|
|
97
|
+
# attached. What travels is the NAME of the class and the preamble lines that
|
|
98
|
+
# go with it.
|
|
99
|
+
LATEX_TEMPLATE_KEYS <- c("label", "documentclass", "classOptions", "citeStyle",
|
|
100
|
+
"citePackage", "topLevel", "preamble", "description", "version")
|
|
101
|
+
LATEX_CITE_STYLES <- c("natbib", "biblatex", "plain")
|
|
102
|
+
LATEX_TOP_LEVELS <- c("section", "chapter")
|
|
103
|
+
LATEX_CLASS_RE <- "^[A-Za-z][A-Za-z0-9._-]{0,39}$"
|
|
104
|
+
LATEX_OPTION_RE <- "^[A-Za-z0-9][A-Za-z0-9 =.,%*-]{0,79}$"
|
|
105
|
+
# `citeStyle` and `citePackage` are TWO decisions that look like one.
|
|
106
|
+
# `citeStyle` says which commands the body gets (\\parencite or \\citep or
|
|
107
|
+
# \\cite); `citePackage` says how the engine is loaded. latexDocument fused
|
|
108
|
+
# them until apa7, which needs biblatex's commands AND [style=apa] — with one
|
|
109
|
+
# key it would have loaded biblatex twice with contradictory options.
|
|
110
|
+
LATEX_CITE_PACKAGE_RE <- "^\\\\usepackage(\\[[^]]{0,120}\\])?\\{[A-Za-z0-9,._-]{1,60}\\}$"
|
|
111
|
+
LATEX_PREAMBLE_MAX_LINES <- 200L
|
|
112
|
+
LATEX_PREAMBLE_MAX_CHARS <- 400L
|
|
113
|
+
|
|
114
|
+
# WHY THIS ROW HAS A DENY-LIST AND THE OTHER TWO DO NOT.
|
|
115
|
+
#
|
|
116
|
+
# A CSL style is XML pandoc parses; it cannot call anything. A theme is a map
|
|
117
|
+
# of colours. A LaTeX preamble is CODE — not code CarmaR runs (the kernel
|
|
118
|
+
# never compiles a .tex; latex.js only writes one) but code the AUTHOR'S TeX
|
|
119
|
+
# engine runs when they build the paper. That is a weaker guarantee than the
|
|
120
|
+
# other two rows make, and it is written here rather than left to be inherited
|
|
121
|
+
# by proximity.
|
|
122
|
+
#
|
|
123
|
+
# What is left of it after the deny-list: shell escape is off by default in
|
|
124
|
+
# every current TeX distribution and needs --shell-escape on the command line,
|
|
125
|
+
# and the person compiling is the person who chose the template. What the
|
|
126
|
+
# list buys is that a template cannot ASK for those doors, so an author who
|
|
127
|
+
# does pass --shell-escape for an unrelated reason is not handing a downloaded
|
|
128
|
+
# file a shell. File inclusion is refused for the same reason a plugin's files
|
|
129
|
+
# must be bare names inside its folder: a template is one file, not a foothold
|
|
130
|
+
# for reading the author's disk into their PDF.
|
|
131
|
+
#
|
|
132
|
+
# A NOTE ON THE BOUNDARY, because the obvious one is wrong. A TeX control word
|
|
133
|
+
# ends at the first NON-LETTER, which is not what `\\b` means: in `\\openout1`
|
|
134
|
+
# the `t` and the `1` are both word characters, so `\\bopenout\\b` does not
|
|
135
|
+
# match the commonest way anyone writes it. `(?![A-Za-z])` is the real rule.
|
|
136
|
+
# It also has to cut both ways — `\\inputencoding` and `\\includegraphics` are
|
|
137
|
+
# ordinary and must NOT be caught by the `input`/`include` row, while
|
|
138
|
+
# `\\readline` must be caught by the file row, which is why only that row
|
|
139
|
+
# tolerates a suffix.
|
|
140
|
+
LATEX_FORBIDDEN <- list(
|
|
141
|
+
list(re = "\\\\write[[:space:]]*18", why = "runs a shell command"),
|
|
142
|
+
list(re = "\\\\(ShellEscape|directlua|luadirect|pdfprimitive|javascript)(?![A-Za-z])", why = "runs code outside TeX"),
|
|
143
|
+
list(re = "\\\\(openout|openin|read|write)[a-z]*(?![A-Za-z])", why = "reads or writes files"),
|
|
144
|
+
list(re = "\\\\(input|include|includeonly|subfile|subimport|import)(?![A-Za-z])", why = "pulls in another file"),
|
|
145
|
+
list(re = "shellesc|\\bshell-?escape", why = "asks for shell escape"),
|
|
146
|
+
list(re = "\\\\documentclass|\\\\begin[[:space:]]*\\{document\\}", why = "is the document's, not the preamble's")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
#' The first forbidden construct in `text`, as a sentence, or "" when clean.
|
|
150
|
+
latex_forbidden_reason <- function(text) {
|
|
151
|
+
for (rule in LATEX_FORBIDDEN) {
|
|
152
|
+
if (any(grepl(rule$re, text, perl = TRUE))) return(rule$why)
|
|
153
|
+
}
|
|
154
|
+
""
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# ── the kinds table ─────────────────────────────────────────────────────────
|
|
158
|
+
#
|
|
159
|
+
# Closed, by design. A row is: what the payload is, who reads it, how big it
|
|
160
|
+
# may be, how it is checked, and — for a kind with a public registry — where
|
|
161
|
+
# it is searched and fetched by name. `validate(dir, file)` returns
|
|
162
|
+
# list(ok, error, name, version, requires) where `requires` names OTHER
|
|
163
|
+
# plugins of the same kind this one renders through (a dependent CSL style's
|
|
164
|
+
# parent); an install completes them from the registry.
|
|
165
|
+
|
|
166
|
+
PLUGIN_KINDS <- list(
|
|
167
|
+
csl = list(
|
|
168
|
+
label = "Citation styles",
|
|
169
|
+
consumer = "pandoc renders citations and the reference list with it; it is placed beside a saved document.",
|
|
170
|
+
extension = "csl",
|
|
171
|
+
max_bytes = 2000000,
|
|
172
|
+
license = "CC BY-SA 3.0",
|
|
173
|
+
registry = function() list(base = CSL_STYLE_BASE(), index = CSL_INDEX_URL()),
|
|
174
|
+
validate = function(dir, file) {
|
|
175
|
+
path <- file.path(dir, file)
|
|
176
|
+
if (!csl_looks_like_style(path)) return(list(ok = FALSE, error = "did not arrive as a CSL style"))
|
|
177
|
+
info <- csl_info(path)
|
|
178
|
+
parent <- sub("\\.csl$", "", info$parent)
|
|
179
|
+
if (nzchar(parent) && !grepl(PLUGIN_ID_RE, parent)) {
|
|
180
|
+
return(list(ok = FALSE, error = "names a parent style this kernel will not fetch"))
|
|
181
|
+
}
|
|
182
|
+
head_text <- paste(readLines(path, n = 80L, warn = FALSE, encoding = "UTF-8"), collapse = "\n")
|
|
183
|
+
updated <- regmatches(head_text, regexpr("<updated>[^<]+</updated>", head_text))
|
|
184
|
+
version <- if (length(updated)) substr(gsub("</?updated>", "", updated[[1L]]), 1L, 10L) else ""
|
|
185
|
+
list(ok = TRUE, name = info$title, version = version,
|
|
186
|
+
requires = if (nzchar(parent)) parent else character(0))
|
|
187
|
+
}),
|
|
188
|
+
theme = list(
|
|
189
|
+
label = "Editor themes",
|
|
190
|
+
consumer = "the editor: chunks, console, script editor and R code in prose.",
|
|
191
|
+
extension = "json",
|
|
192
|
+
max_bytes = 65536,
|
|
193
|
+
license = "",
|
|
194
|
+
registry = NULL,
|
|
195
|
+
validate = function(dir, file) {
|
|
196
|
+
doc <- tryCatch(jsonlite::fromJSON(file.path(dir, file), simplifyVector = FALSE), error = function(e) e)
|
|
197
|
+
if (inherits(doc, "error")) return(list(ok = FALSE, error = paste0("is not valid JSON: ", conditionMessage(doc))))
|
|
198
|
+
label <- doc$label
|
|
199
|
+
if (!is.character(label) || length(label) != 1L || !nzchar(trimws(label)) || nchar(label) > 60L) {
|
|
200
|
+
return(list(ok = FALSE, error = "needs a `label` of up to 60 characters"))
|
|
201
|
+
}
|
|
202
|
+
if (!is.logical(doc$dark) || length(doc$dark) != 1L || is.na(doc$dark)) {
|
|
203
|
+
return(list(ok = FALSE, error = "needs `dark`: true or false"))
|
|
204
|
+
}
|
|
205
|
+
vars <- doc$vars
|
|
206
|
+
if (!is.list(vars) || is.null(names(vars)) || !length(vars)) return(list(ok = FALSE, error = "needs a `vars` object of editor tokens"))
|
|
207
|
+
unknown <- setdiff(names(vars), THEME_TOKENS)
|
|
208
|
+
if (length(unknown)) return(list(ok = FALSE, error = paste0("sets tokens the editor does not have: ", paste(head(unknown, 3L), collapse = ", "))))
|
|
209
|
+
if (!all(c("--ed-bg", "--ed-fg") %in% names(vars))) return(list(ok = FALSE, error = "must set at least --ed-bg and --ed-fg"))
|
|
210
|
+
bad <- vapply(names(vars), function(k) {
|
|
211
|
+
v <- vars[[k]]
|
|
212
|
+
if (!is.character(v) || length(v) != 1L) return(TRUE)
|
|
213
|
+
hex <- grepl("^#[0-9a-fA-F]{6}$", v)
|
|
214
|
+
wash <- k %in% THEME_WASH_TOKENS && grepl("^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*\\)$", v)
|
|
215
|
+
!(hex || wash)
|
|
216
|
+
}, logical(1))
|
|
217
|
+
if (any(bad)) return(list(ok = FALSE, error = paste0("colour for ", names(vars)[bad][[1L]], " must be #rrggbb (a wash may be rgba())")))
|
|
218
|
+
version <- doc$version
|
|
219
|
+
version <- if (is.character(version) && length(version) == 1L && !is.na(version)) substr(version, 1L, 40L) else ""
|
|
220
|
+
list(ok = TRUE, name = trimws(label), version = version, requires = character(0))
|
|
221
|
+
}),
|
|
222
|
+
latex = list(
|
|
223
|
+
label = "LaTeX templates",
|
|
224
|
+
consumer = "the LaTeX export: it sets the document class, its options and the preamble of the .tex that is written.",
|
|
225
|
+
extension = "json",
|
|
226
|
+
max_bytes = 65536,
|
|
227
|
+
license = "",
|
|
228
|
+
registry = NULL,
|
|
229
|
+
validate = function(dir, file) {
|
|
230
|
+
doc <- tryCatch(jsonlite::fromJSON(file.path(dir, file), simplifyVector = FALSE), error = function(e) e)
|
|
231
|
+
if (inherits(doc, "error")) return(list(ok = FALSE, error = paste0("is not valid JSON: ", conditionMessage(doc))))
|
|
232
|
+
if (!is.list(doc) || is.null(names(doc)) || !length(doc)) return(list(ok = FALSE, error = "is not a template object"))
|
|
233
|
+
unknown <- setdiff(names(doc), LATEX_TEMPLATE_KEYS)
|
|
234
|
+
if (length(unknown)) {
|
|
235
|
+
return(list(ok = FALSE, error = paste0("sets keys a template does not have: ", paste(utils::head(unknown, 3L), collapse = ", "))))
|
|
236
|
+
}
|
|
237
|
+
one <- function(v, max) is.character(v) && length(v) == 1L && !is.na(v) && nzchar(trimws(v)) && nchar(v) <= max
|
|
238
|
+
strings <- function(v) {
|
|
239
|
+
if (is.null(v)) return(character(0))
|
|
240
|
+
if (!is.list(v) && !is.character(v)) return(NULL)
|
|
241
|
+
out <- unlist(v, use.names = FALSE)
|
|
242
|
+
if (!length(out)) return(character(0))
|
|
243
|
+
if (!is.character(out) || anyNA(out)) return(NULL)
|
|
244
|
+
out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!one(doc$label, 60L)) return(list(ok = FALSE, error = "needs a `label` of up to 60 characters"))
|
|
248
|
+
if (!one(doc$documentclass, 40L) || !grepl(LATEX_CLASS_RE, doc$documentclass)) {
|
|
249
|
+
return(list(ok = FALSE, error = "needs a `documentclass` that is a plain class name (letters, digits, . _ -)"))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
opts <- strings(doc$classOptions)
|
|
253
|
+
if (is.null(opts)) return(list(ok = FALSE, error = "`classOptions` must be a list of strings"))
|
|
254
|
+
if (length(opts) > 12L) return(list(ok = FALSE, error = "names more class options than a class takes"))
|
|
255
|
+
bad <- opts[!grepl(LATEX_OPTION_RE, opts)]
|
|
256
|
+
if (length(bad)) return(list(ok = FALSE, error = paste0("has a class option that is not a plain option: ", bad[[1L]])))
|
|
257
|
+
|
|
258
|
+
pre <- strings(doc$preamble)
|
|
259
|
+
if (is.null(pre)) return(list(ok = FALSE, error = "`preamble` must be a list of lines"))
|
|
260
|
+
if (length(pre) > LATEX_PREAMBLE_MAX_LINES) {
|
|
261
|
+
return(list(ok = FALSE, error = sprintf("has a preamble longer than %d lines", LATEX_PREAMBLE_MAX_LINES)))
|
|
262
|
+
}
|
|
263
|
+
long <- pre[nchar(pre) > LATEX_PREAMBLE_MAX_CHARS]
|
|
264
|
+
if (length(long)) return(list(ok = FALSE, error = sprintf("has a preamble line longer than %d characters", LATEX_PREAMBLE_MAX_CHARS)))
|
|
265
|
+
|
|
266
|
+
# The deny-list, over everything that reaches the .tex. See LATEX_FORBIDDEN.
|
|
267
|
+
why <- latex_forbidden_reason(c(doc$documentclass, opts, pre, as.character(doc$citePackage %||% "")))
|
|
268
|
+
if (nzchar(why)) return(list(ok = FALSE, error = paste0("contains LaTeX that ", why, ", which a template may not do")))
|
|
269
|
+
|
|
270
|
+
if (!is.null(doc$citeStyle) && !(one(doc$citeStyle, 20L) && doc$citeStyle %in% LATEX_CITE_STYLES)) {
|
|
271
|
+
return(list(ok = FALSE, error = paste0("`citeStyle` must be one of ", paste(LATEX_CITE_STYLES, collapse = ", "))))
|
|
272
|
+
}
|
|
273
|
+
if (!is.null(doc$citePackage) && !(one(doc$citePackage, 200L) && grepl(LATEX_CITE_PACKAGE_RE, doc$citePackage))) {
|
|
274
|
+
return(list(ok = FALSE, error = "`citePackage` must be a single \\usepackage line"))
|
|
275
|
+
}
|
|
276
|
+
if (!is.null(doc$topLevel) && !(one(doc$topLevel, 20L) && doc$topLevel %in% LATEX_TOP_LEVELS)) {
|
|
277
|
+
return(list(ok = FALSE, error = paste0("`topLevel` must be one of ", paste(LATEX_TOP_LEVELS, collapse = ", "))))
|
|
278
|
+
}
|
|
279
|
+
if (!is.null(doc$description) && !one(doc$description, 200L)) {
|
|
280
|
+
return(list(ok = FALSE, error = "`description` must be a sentence of up to 200 characters"))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
version <- doc$version
|
|
284
|
+
version <- if (is.character(version) && length(version) == 1L && !is.na(version)) substr(version, 1L, 40L) else ""
|
|
285
|
+
list(ok = TRUE, name = trimws(doc$label), version = version, requires = character(0))
|
|
286
|
+
})
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
plugin_kind_ok <- function(kind) is.character(kind) && length(kind) == 1L && !is.na(kind) && kind %in% names(PLUGIN_KINDS)
|
|
290
|
+
plugin_id_ok <- function(id) is.character(id) && length(id) == 1L && !is.na(id) && grepl(PLUGIN_ID_RE, id)
|
|
291
|
+
plugin_file_ok <- function(file) is.character(file) && length(file) == 1L && !is.na(file) && grepl(PLUGIN_FILE_RE, file) &&
|
|
292
|
+
!grepl("\\.\\.", file, fixed = FALSE)
|
|
293
|
+
|
|
294
|
+
policy_split <- if (exists("policy_split")) policy_split else function(value) {
|
|
295
|
+
parts <- trimws(unlist(strsplit(value %||% "", "[,[:space:]]+")))
|
|
296
|
+
parts[nzchar(parts)]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#' The host of a URL, lower-cased, or "" when it has none.
|
|
300
|
+
url_host <- function(url) {
|
|
301
|
+
m <- regmatches(url, regexpr("^https?://[^/?#]+", url))
|
|
302
|
+
if (!length(m)) return("")
|
|
303
|
+
tolower(sub("^https?://", "", sub(":[0-9]+$", "", m[[1L]])))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#' The hosts an install may fetch from when no policy names any: the
|
|
307
|
+
#' registries of the kinds themselves. Computed, not written down, so a test
|
|
308
|
+
#' pointing the CSL registry at 127.0.0.1 permits 127.0.0.1 without a second
|
|
309
|
+
#' override.
|
|
310
|
+
plugin_default_hosts <- function() {
|
|
311
|
+
hosts <- unlist(lapply(PLUGIN_KINDS, function(k) {
|
|
312
|
+
if (is.null(k$registry)) return(character(0))
|
|
313
|
+
r <- k$registry()
|
|
314
|
+
c(url_host(r$base), url_host(r$index))
|
|
315
|
+
}))
|
|
316
|
+
unique(hosts[nzchar(hosts)])
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#' Read the administrator's plugin policy.
|
|
320
|
+
#'
|
|
321
|
+
#' @param env a getter with `Sys.getenv`'s signature; injected for tests.
|
|
322
|
+
#' @param default_file the file read when CARMAR_PLUGIN_POLICY is unset, if it exists.
|
|
323
|
+
#' @param default_dir the system layer when CARMAR_PLUGIN_DIR is unset, if it exists.
|
|
324
|
+
#' @return list(set, source, install, sources, network, kinds, unknown, note,
|
|
325
|
+
#' system_dir, errors). `kinds` is the permitted kinds — every kind when
|
|
326
|
+
#' unset. `sources` is the permitted hosts; `network` FALSE when none may
|
|
327
|
+
#' be contacted (`sources: none`, or an empty valid list).
|
|
328
|
+
#'
|
|
329
|
+
#' Three states, as in ai-policy.R: names nothing → everything; names
|
|
330
|
+
#' something valid → exactly that; names ONLY junk → an error, because an
|
|
331
|
+
#' empty allow-list that fails open is the bug the AI policy suite caught.
|
|
332
|
+
carmar_plugin_policy <- function(env = Sys.getenv, default_file = PLUGIN_POLICY_DEFAULT_FILE,
|
|
333
|
+
default_dir = PLUGIN_SYSTEM_DEFAULT_DIR) {
|
|
334
|
+
errors <- character(0)
|
|
335
|
+
source <- ""
|
|
336
|
+
install <- ""
|
|
337
|
+
sources_raw <- NULL
|
|
338
|
+
kinds_raw <- NULL
|
|
339
|
+
note <- ""
|
|
340
|
+
system_dir <- trimws(env("CARMAR_PLUGIN_DIR", ""))
|
|
341
|
+
if (!nzchar(system_dir) && nzchar(default_dir) && dir.exists(default_dir)) system_dir <- default_dir
|
|
342
|
+
|
|
343
|
+
path <- trimws(env("CARMAR_PLUGIN_POLICY", ""))
|
|
344
|
+
if (!nzchar(path) && nzchar(default_file) && file.exists(default_file)) path <- default_file
|
|
345
|
+
if (nzchar(path)) {
|
|
346
|
+
source <- path
|
|
347
|
+
if (!file.exists(path)) {
|
|
348
|
+
errors <- c(errors, paste0("CARMAR_PLUGIN_POLICY names a file that does not exist: ", path))
|
|
349
|
+
} else {
|
|
350
|
+
doc <- tryCatch(jsonlite::fromJSON(path, simplifyVector = TRUE), error = function(e) e)
|
|
351
|
+
if (inherits(doc, "error")) {
|
|
352
|
+
errors <- c(errors, paste0("CARMAR_PLUGIN_POLICY is not valid JSON: ", conditionMessage(doc)))
|
|
353
|
+
} else {
|
|
354
|
+
install <- trimws(as.character(doc$install %||% "")[1] %||% "")
|
|
355
|
+
if (!is.null(doc$sources)) sources_raw <- as.character(unlist(doc$sources))
|
|
356
|
+
if (!is.null(doc$kinds)) kinds_raw <- as.character(unlist(doc$kinds))
|
|
357
|
+
note <- trimws(as.character(doc$note %||% "")[1] %||% "")
|
|
358
|
+
if (is.na(note)) note <- ""
|
|
359
|
+
if (is.na(install)) install <- ""
|
|
360
|
+
d <- trimws(as.character(doc$system_dir %||% "")[1] %||% "")
|
|
361
|
+
if (!is.na(d) && nzchar(d)) system_dir <- d
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} else {
|
|
365
|
+
if (nzchar(env("CARMAR_PLUGIN_INSTALL", ""))) { source <- "env"; install <- trimws(env("CARMAR_PLUGIN_INSTALL", "")) }
|
|
366
|
+
if (nzchar(env("CARMAR_PLUGIN_SOURCES", ""))) { source <- "env"; sources_raw <- policy_split(env("CARMAR_PLUGIN_SOURCES", "")) }
|
|
367
|
+
if (nzchar(env("CARMAR_PLUGIN_KINDS", ""))) { source <- "env"; kinds_raw <- policy_split(env("CARMAR_PLUGIN_KINDS", "")) }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!nzchar(install)) install <- "user"
|
|
371
|
+
if (!install %in% c("user", "none")) {
|
|
372
|
+
errors <- c(errors, paste0("The plugin policy's `install` must be \"user\" or \"none\", not \"", install, "\"."))
|
|
373
|
+
install <- "user"
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# Kinds: three states.
|
|
377
|
+
kinds_named <- unique(trimws(kinds_raw %||% character(0)))
|
|
378
|
+
kinds_named <- kinds_named[nzchar(kinds_named)]
|
|
379
|
+
unknown <- setdiff(kinds_named, names(PLUGIN_KINDS))
|
|
380
|
+
kinds <- if (length(kinds_named)) intersect(kinds_named, names(PLUGIN_KINDS)) else names(PLUGIN_KINDS)
|
|
381
|
+
if (length(kinds_named) && !length(kinds)) {
|
|
382
|
+
errors <- c(errors, paste0("The plugin policy permits no plugin kind at all (unrecognised: ",
|
|
383
|
+
paste(unknown, collapse = ", "), "). Name at least one of ",
|
|
384
|
+
paste(names(PLUGIN_KINDS), collapse = ", "), ", or remove the policy."))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
# Sources: an absent list means the kinds' own registries; "none" means no
|
|
388
|
+
# network at all; anything else is an exact host list (an administrator's
|
|
389
|
+
# mirror). A host is a host — no scheme, no path — and https is required
|
|
390
|
+
# of every fetch regardless of the list.
|
|
391
|
+
network <- TRUE
|
|
392
|
+
sources <- character(0)
|
|
393
|
+
if (is.null(sources_raw)) {
|
|
394
|
+
sources <- plugin_default_hosts()
|
|
395
|
+
} else {
|
|
396
|
+
named <- unique(tolower(trimws(sources_raw)))
|
|
397
|
+
named <- named[nzchar(named)]
|
|
398
|
+
if (identical(named, "none") || !length(named)) {
|
|
399
|
+
network <- FALSE
|
|
400
|
+
} else {
|
|
401
|
+
bad <- named[!grepl("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", named)]
|
|
402
|
+
if (length(bad)) errors <- c(errors, paste0("The plugin policy's `sources` must be bare host names: ", paste(bad, collapse = ", ")))
|
|
403
|
+
sources <- setdiff(named, bad)
|
|
404
|
+
if (!length(sources)) network <- FALSE
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
set <- nzchar(source) && !length(errors)
|
|
409
|
+
if (length(errors)) { install <- "user"; kinds <- names(PLUGIN_KINDS); sources <- plugin_default_hosts(); network <- TRUE }
|
|
410
|
+
note <- gsub("[[:cntrl:]]", " ", note)
|
|
411
|
+
note <- trimws(substr(note, 1L, 400L))
|
|
412
|
+
|
|
413
|
+
list(set = set, source = source, install = install, sources = sources, network = network,
|
|
414
|
+
kinds = kinds, unknown = unknown, note = note, system_dir = system_dir, errors = errors)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#' The sentence shown when the policy refuses something.
|
|
418
|
+
plugin_policy_reason <- function(policy, what = "install") {
|
|
419
|
+
base <- switch(what,
|
|
420
|
+
install = "This CarmaR is configured so that plugins are not installed by users; the set is fixed by the deployment.",
|
|
421
|
+
network = "This CarmaR is configured to fetch no plugins from the network; a plugin arrives as a file.",
|
|
422
|
+
host = sprintf("This CarmaR is configured to fetch plugins only from: %s.", paste(policy$sources, collapse = ", ")),
|
|
423
|
+
kind = "This CarmaR is configured without that plugin kind.",
|
|
424
|
+
"This CarmaR's plugin policy refuses that.")
|
|
425
|
+
if (nzchar(policy$note)) paste(base, policy$note) else base
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
# ── layers and paths ─────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
plugin_user_dir <- function() file.path(tools::R_user_dir("carmar", "data"), "plugins")
|
|
431
|
+
|
|
432
|
+
#' The layers, in precedence order. The user layer is absent under
|
|
433
|
+
#' `install: none` — not read-only, ABSENT, because "the set is fixed by the
|
|
434
|
+
#' deployment" and a person's earlier imports quietly outranking it would
|
|
435
|
+
#' make that sentence false.
|
|
436
|
+
plugin_layers <- function(here, policy) {
|
|
437
|
+
layers <- list()
|
|
438
|
+
if (identical(policy$install, "user")) layers <- c(layers, list(list(name = "user", dir = plugin_user_dir(), writable = TRUE)))
|
|
439
|
+
if (nzchar(policy$system_dir)) layers <- c(layers, list(list(name = "system", dir = policy$system_dir, writable = FALSE)))
|
|
440
|
+
c(layers, list(list(name = "shipped", dir = file.path(here, "plugins"), writable = FALSE)))
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#' A path INSIDE `base`, or NULL. The regexes on id and file already forbid a
|
|
444
|
+
#' separator and `..`; this is the second lock — resolved paths, compared —
|
|
445
|
+
#' so a symlink or an encoding trick a regex did not foresee still cannot
|
|
446
|
+
#' reach out of the layer. `mustWork = FALSE` because the target may not
|
|
447
|
+
#' exist yet at install.
|
|
448
|
+
plugin_contained <- function(base, ...) {
|
|
449
|
+
if (!dir.exists(base)) return(NULL)
|
|
450
|
+
root <- normalizePath(base, winslash = "/", mustWork = TRUE)
|
|
451
|
+
parts <- c(...)
|
|
452
|
+
if (!length(parts) || !all(vapply(parts, function(x) is.character(x) && length(x) == 1L && !is.na(x) &&
|
|
453
|
+
!grepl("[/\\\\]|^\\.\\.?$", x), logical(1)))) return(NULL)
|
|
454
|
+
path <- file.path(root, paste(parts, collapse = "/"))
|
|
455
|
+
# An existing target is resolved through any symlink before the check;
|
|
456
|
+
# one that does not exist yet is a plain child of the resolved root by
|
|
457
|
+
# construction (no separator, no dot segment — refused above).
|
|
458
|
+
if (file.exists(path)) path <- normalizePath(path, winslash = "/", mustWork = TRUE)
|
|
459
|
+
if (!identical(path, root) && !startsWith(path, paste0(root, "/"))) return(NULL)
|
|
460
|
+
path
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
plugin_digest <- function(path) {
|
|
464
|
+
if (exists("sha256sum", asNamespace("tools"))) {
|
|
465
|
+
list(algo = "sha256", value = unname(tools::sha256sum(path)))
|
|
466
|
+
} else {
|
|
467
|
+
list(algo = "md5", value = unname(tools::md5sum(path)))
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
# ── the manifest ────────────────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
#' Build the manifest for a validated payload sitting in `dir`.
|
|
474
|
+
plugin_manifest_for <- function(dir, kind, id, files, info, source, license = "") {
|
|
475
|
+
digests <- lapply(files, function(f) plugin_digest(file.path(dir, f)))
|
|
476
|
+
names(digests) <- files
|
|
477
|
+
algo <- digests[[1L]]$algo
|
|
478
|
+
list(
|
|
479
|
+
id = id, kind = kind,
|
|
480
|
+
name = substr(info$name %||% id, 1L, 200L),
|
|
481
|
+
version = info$version %||% "",
|
|
482
|
+
file = files[[1L]], files = I(files),
|
|
483
|
+
requires = I(as.character(info$requires %||% character(0))),
|
|
484
|
+
license = license %||% "",
|
|
485
|
+
source = source,
|
|
486
|
+
digest = algo,
|
|
487
|
+
sha256 = if (identical(algo, "sha256")) lapply(digests, function(d) d$value) else NULL,
|
|
488
|
+
md5 = if (identical(algo, "md5")) lapply(digests, function(d) d$value) else NULL,
|
|
489
|
+
bytes = sum(vapply(files, function(f) as.numeric(file.info(file.path(dir, f))$size), numeric(1))),
|
|
490
|
+
installed = format(Sys.time(), "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"))
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
plugin_manifest_write <- function(dir, manifest) {
|
|
494
|
+
manifest <- manifest[!vapply(manifest, is.null, logical(1))]
|
|
495
|
+
writeLines(jsonlite::toJSON(manifest, auto_unbox = TRUE, pretty = TRUE, null = "null"),
|
|
496
|
+
file.path(dir, PLUGIN_MANIFEST), useBytes = TRUE)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#' Read and CHECK one plugin folder. Returns a row-shaped list with
|
|
500
|
+
#' `status` "ok" or "broken" and a `reason`; a folder is never trusted for
|
|
501
|
+
#' what its manifest claims.
|
|
502
|
+
plugin_inspect <- function(layer, kind, id) {
|
|
503
|
+
dir <- plugin_contained(file.path(layer$dir, kind), id)
|
|
504
|
+
row <- list(kind = kind, id = id, name = id, version = "", layer = layer$name, dir = dir %||% "",
|
|
505
|
+
file = "", files = character(0), requires = character(0), source = "", license = "",
|
|
506
|
+
installed = "", bytes = 0, status = "broken", reason = "", verified = FALSE)
|
|
507
|
+
if (is.null(dir) || !dir.exists(dir)) { row$reason <- "the folder is outside its layer"; return(row) }
|
|
508
|
+
mpath <- file.path(dir, PLUGIN_MANIFEST)
|
|
509
|
+
if (!file.exists(mpath)) { row$reason <- "no plugin.json"; return(row) }
|
|
510
|
+
if (file.info(mpath)$size > PLUGIN_MANIFEST_MAX_BYTES) { row$reason <- "plugin.json is too large to be one"; return(row) }
|
|
511
|
+
m <- tryCatch(jsonlite::fromJSON(mpath, simplifyVector = TRUE), error = function(e) e)
|
|
512
|
+
if (inherits(m, "error")) { row$reason <- paste0("plugin.json is not valid JSON: ", conditionMessage(m)); return(row) }
|
|
513
|
+
if (!identical(m$id, id)) { row$reason <- "plugin.json names a different id than its folder"; return(row) }
|
|
514
|
+
if (!identical(m$kind, kind)) { row$reason <- "plugin.json names a different kind than its folder"; return(row) }
|
|
515
|
+
files <- as.character(m$files %||% m$file %||% character(0))
|
|
516
|
+
if (!length(files) || !all(vapply(files, plugin_file_ok, logical(1)))) { row$reason <- "plugin.json lists no valid file"; return(row) }
|
|
517
|
+
spec <- PLUGIN_KINDS[[kind]]
|
|
518
|
+
if (!grepl(paste0("\\.", spec$extension, "$"), files[[1L]])) { row$reason <- paste0("the payload is not a .", spec$extension, " file"); return(row) }
|
|
519
|
+
row$name <- substr(as.character(m$name %||% id)[1L], 1L, 200L)
|
|
520
|
+
row$version <- as.character(m$version %||% "")[1L]
|
|
521
|
+
row$file <- files[[1L]]; row$files <- files
|
|
522
|
+
row$requires <- as.character(unlist(m$requires %||% character(0)))
|
|
523
|
+
row$source <- as.character(m$source %||% "")[1L]
|
|
524
|
+
row$license <- as.character(m$license %||% "")[1L]
|
|
525
|
+
row$installed <- as.character(m$installed %||% "")[1L]
|
|
526
|
+
if (is.na(row$name)) row$name <- id
|
|
527
|
+
for (f in files) { # a handful of files at most
|
|
528
|
+
p <- plugin_contained(dir, f)
|
|
529
|
+
if (is.null(p) || !file.exists(p)) { row$reason <- paste0(f, " is missing"); return(row) }
|
|
530
|
+
size <- file.info(p)$size
|
|
531
|
+
if (is.na(size) || size > spec$max_bytes) { row$reason <- paste0(f, " is larger than this kind allows"); return(row) }
|
|
532
|
+
row$bytes <- row$bytes + size
|
|
533
|
+
}
|
|
534
|
+
# Integrity: the recorded digest, recomputed. A manifest carrying sha256
|
|
535
|
+
# on an R that cannot compute it (< 4.5) is listed `verified = FALSE`
|
|
536
|
+
# rather than refused — the file is still the file; only the proof is
|
|
537
|
+
# unavailable here, and the row says so.
|
|
538
|
+
algo <- as.character(m$digest %||% if (!is.null(m$sha256)) "sha256" else if (!is.null(m$md5)) "md5" else "")[1L]
|
|
539
|
+
recorded <- if (identical(algo, "sha256")) m$sha256 else if (identical(algo, "md5")) m$md5 else NULL
|
|
540
|
+
can <- identical(algo, "md5") || (identical(algo, "sha256") && exists("sha256sum", asNamespace("tools")))
|
|
541
|
+
if (nzchar(algo) && !is.null(recorded) && can) {
|
|
542
|
+
for (f in files) {
|
|
543
|
+
want <- as.character(recorded[[f]] %||% "")
|
|
544
|
+
have <- if (identical(algo, "sha256")) unname(tools::sha256sum(file.path(dir, f))) else unname(tools::md5sum(file.path(dir, f)))
|
|
545
|
+
if (!nzchar(want) || !identical(tolower(want), tolower(have))) {
|
|
546
|
+
row$reason <- paste0(f, " has changed since it was installed (", algo, " differs)")
|
|
547
|
+
return(row)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
row$verified <- TRUE
|
|
551
|
+
}
|
|
552
|
+
row$status <- "ok"
|
|
553
|
+
row
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#' Every plugin in every layer, checked, one row each.
|
|
557
|
+
#'
|
|
558
|
+
#' @return data.frame(kind, id, name, version, layer, dir, file, requires
|
|
559
|
+
#' (";"-joined), files (";"-joined), source, license, installed, bytes, status, reason,
|
|
560
|
+
#' verified, active). `active` marks the copy consumers use: the first OK
|
|
561
|
+
#' copy of an id in layer order. Kinds the policy does not permit are not
|
|
562
|
+
#' listed at all.
|
|
563
|
+
plugin_rows <- function(here, policy, kind = NULL) {
|
|
564
|
+
empty <- data.frame(kind = character(), id = character(), name = character(), version = character(),
|
|
565
|
+
layer = character(), dir = character(), file = character(), files = character(), requires = character(),
|
|
566
|
+
source = character(), license = character(), installed = character(), bytes = numeric(),
|
|
567
|
+
status = character(), reason = character(), verified = logical(), active = logical(),
|
|
568
|
+
stringsAsFactors = FALSE)
|
|
569
|
+
kinds <- if (is.null(kind)) policy$kinds else intersect(kind, policy$kinds)
|
|
570
|
+
rows <- list()
|
|
571
|
+
for (layer in plugin_layers(here, policy)) { # three layers at most
|
|
572
|
+
for (k in kinds) { # a handful of kinds
|
|
573
|
+
kdir <- file.path(layer$dir, k)
|
|
574
|
+
if (!dir.exists(kdir)) next
|
|
575
|
+
ids <- sort(list.files(kdir, full.names = FALSE, no.. = TRUE))
|
|
576
|
+
ids <- ids[!startsWith(ids, ".") & dir.exists(file.path(kdir, ids))]
|
|
577
|
+
ids <- ids[grepl(PLUGIN_ID_RE, ids)]
|
|
578
|
+
rows <- c(rows, lapply(ids, function(id) plugin_inspect(layer, k, id)))
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (!length(rows)) return(empty)
|
|
582
|
+
out <- data.frame(
|
|
583
|
+
kind = vapply(rows, `[[`, character(1), "kind"), id = vapply(rows, `[[`, character(1), "id"),
|
|
584
|
+
name = vapply(rows, `[[`, character(1), "name"), version = vapply(rows, `[[`, character(1), "version"),
|
|
585
|
+
layer = vapply(rows, `[[`, character(1), "layer"), dir = vapply(rows, `[[`, character(1), "dir"),
|
|
586
|
+
file = vapply(rows, `[[`, character(1), "file"),
|
|
587
|
+
files = vapply(rows, function(r) paste(r$files, collapse = ";"), character(1)),
|
|
588
|
+
requires = vapply(rows, function(r) paste(r$requires, collapse = ";"), character(1)),
|
|
589
|
+
source = vapply(rows, `[[`, character(1), "source"), license = vapply(rows, `[[`, character(1), "license"),
|
|
590
|
+
installed = vapply(rows, `[[`, character(1), "installed"), bytes = vapply(rows, `[[`, numeric(1), "bytes"),
|
|
591
|
+
status = vapply(rows, `[[`, character(1), "status"), reason = vapply(rows, `[[`, character(1), "reason"),
|
|
592
|
+
verified = vapply(rows, `[[`, logical(1), "verified"),
|
|
593
|
+
stringsAsFactors = FALSE)
|
|
594
|
+
key <- paste(out$kind, out$id)
|
|
595
|
+
out$active <- out$status == "ok" & !duplicated(ifelse(out$status == "ok", key, paste0(key, "#", seq_len(nrow(out)))))
|
|
596
|
+
rownames(out) <- NULL
|
|
597
|
+
out
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
#' The active copy of one plugin, or NULL.
|
|
601
|
+
plugin_active <- function(here, policy, kind, id) {
|
|
602
|
+
if (!plugin_kind_ok(kind) || !plugin_id_ok(id)) return(NULL)
|
|
603
|
+
rows <- plugin_rows(here, policy, kind)
|
|
604
|
+
hit <- rows[rows$id == id & rows$active, , drop = FALSE]
|
|
605
|
+
if (!nrow(hit)) return(NULL)
|
|
606
|
+
as.list(hit[1L, ])
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#' The text of one manifest-listed file of an active plugin.
|
|
610
|
+
plugin_read <- function(here, policy, kind, id, file = NULL) {
|
|
611
|
+
row <- plugin_active(here, policy, kind, id)
|
|
612
|
+
if (is.null(row)) return(list(ok = FALSE, error = "No such plugin."))
|
|
613
|
+
file <- file %||% row$file
|
|
614
|
+
listed <- strsplit(row$files, ";")[[1L]]
|
|
615
|
+
if (!plugin_file_ok(file) || !file %in% listed) return(list(ok = FALSE, error = "That file is not part of the plugin."))
|
|
616
|
+
path <- plugin_contained(row$dir, file)
|
|
617
|
+
if (is.null(path)) return(list(ok = FALSE, error = "That file is not inside the plugin."))
|
|
618
|
+
text <- tryCatch(paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n"), error = function(e) NULL)
|
|
619
|
+
if (is.null(text)) return(list(ok = FALSE, error = "The file could not be read."))
|
|
620
|
+
list(ok = TRUE, kind = kind, id = id, file = file, name = row$name, text = text)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
# ── fetching ────────────────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
#' Fetch one URL into `dest`, bounded, from a permitted host only.
|
|
626
|
+
#' https everywhere but loopback (the test registry); the host must be on
|
|
627
|
+
#' the policy's list. Returns "" or the reason.
|
|
628
|
+
plugin_fetch <- function(policy, url, dest, max_bytes) {
|
|
629
|
+
if (!isTRUE(policy$network)) return(plugin_policy_reason(policy, "network"))
|
|
630
|
+
if (!is.character(url) || length(url) != 1L || is.na(url)) return("not a URL")
|
|
631
|
+
host <- url_host(url)
|
|
632
|
+
loopback <- host %in% c("127.0.0.1", "localhost")
|
|
633
|
+
if (!grepl("^https://", url) && !(loopback && grepl("^http://", url))) return("only https is fetched")
|
|
634
|
+
if (!nzchar(host) || !host %in% policy$sources) return(plugin_policy_reason(policy, "host"))
|
|
635
|
+
old <- options(timeout = PLUGIN_FETCH_TIMEOUT_S); on.exit(options(old), add = TRUE)
|
|
636
|
+
status <- tryCatch(
|
|
637
|
+
suppressWarnings(utils::download.file(url, dest, method = "libcurl", quiet = TRUE, mode = "wb")),
|
|
638
|
+
error = function(e) conditionMessage(e))
|
|
639
|
+
if (!identical(status, 0L)) {
|
|
640
|
+
unlink(dest)
|
|
641
|
+
return(if (is.character(status)) status else paste("download returned", status))
|
|
642
|
+
}
|
|
643
|
+
size <- file.info(dest)$size
|
|
644
|
+
if (is.na(size) || size > max_bytes) {
|
|
645
|
+
unlink(dest)
|
|
646
|
+
return(sprintf("the file is larger than this kind allows (%d KB)", as.integer(max_bytes / 1000)))
|
|
647
|
+
}
|
|
648
|
+
""
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
# ── install and remove ──────────────────────────────────────────────────────
|
|
652
|
+
|
|
653
|
+
plugin_stage_dir <- function(user_dir) {
|
|
654
|
+
staging <- file.path(user_dir, ".staging")
|
|
655
|
+
dir.create(staging, recursive = TRUE, showWarnings = FALSE, mode = "0700")
|
|
656
|
+
d <- tempfile("plugin-", tmpdir = staging)
|
|
657
|
+
dir.create(d, mode = "0700")
|
|
658
|
+
d
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
#' Install one plugin into the user layer.
|
|
662
|
+
#'
|
|
663
|
+
#' @param from one of list(registry = "<name>") · list(url = "<https://…>") ·
|
|
664
|
+
#' list(text = "<contents>", filename = "<name.ext>")
|
|
665
|
+
#' @param complete install the plugins this one `requires` from the registry
|
|
666
|
+
#' (a dependent CSL style's parent). One level: a parent is independent.
|
|
667
|
+
#' @return list(ok = TRUE, kind, id, name, version, file, requires,
|
|
668
|
+
#' already = FALSE) or list(ok = FALSE, error)
|
|
669
|
+
plugin_install <- function(here, policy, kind, from, complete = TRUE) {
|
|
670
|
+
if (!plugin_kind_ok(kind)) return(list(ok = FALSE, error = "That is not a plugin kind this kernel has."))
|
|
671
|
+
if (!kind %in% policy$kinds) return(list(ok = FALSE, error = plugin_policy_reason(policy, "kind")))
|
|
672
|
+
if (!identical(policy$install, "user")) return(list(ok = FALSE, error = plugin_policy_reason(policy, "install")))
|
|
673
|
+
if (!is.list(from)) return(list(ok = FALSE, error = "`from` must say where the plugin comes from."))
|
|
674
|
+
spec <- PLUGIN_KINDS[[kind]]
|
|
675
|
+
ext <- paste0(".", spec$extension)
|
|
676
|
+
|
|
677
|
+
# The id and the source, from whichever door.
|
|
678
|
+
scalar <- function(x) is.character(x) && length(x) == 1L && !is.na(x)
|
|
679
|
+
if (scalar(from$registry)) {
|
|
680
|
+
if (is.null(spec$registry)) return(list(ok = FALSE, error = "That plugin kind has no registry to fetch by name from."))
|
|
681
|
+
id <- from$registry
|
|
682
|
+
if (!plugin_id_ok(id)) return(list(ok = FALSE, error = "That is not a name from the registry."))
|
|
683
|
+
source <- paste0(spec$registry()$base, id)
|
|
684
|
+
door <- "registry"
|
|
685
|
+
} else if (scalar(from$url)) {
|
|
686
|
+
source <- from$url
|
|
687
|
+
id <- sub(paste0("\\", ext, "$"), "", basename(sub("[?#].*$", "", source)))
|
|
688
|
+
if (!plugin_id_ok(id)) return(list(ok = FALSE, error = "The URL does not end in a name a plugin can have (lower-case letters, digits, . _ -)."))
|
|
689
|
+
door <- "url"
|
|
690
|
+
} else if (scalar(from$text) && scalar(from$filename)) {
|
|
691
|
+
fname <- from$filename # a picked file's own name: a path in it is not a name
|
|
692
|
+
if (!plugin_file_ok(fname) || !grepl(paste0("\\", ext, "$"), fname, ignore.case = TRUE)) {
|
|
693
|
+
return(list(ok = FALSE, error = paste0("The file must be a ", ext, " file with a plain name.")))
|
|
694
|
+
}
|
|
695
|
+
id <- tolower(sub(paste0("\\", ext, "$"), "", fname, ignore.case = TRUE))
|
|
696
|
+
if (!plugin_id_ok(id)) return(list(ok = FALSE, error = "The file's name is not one a plugin can have (lower-case letters, digits, . _ -)."))
|
|
697
|
+
if (nchar(from$text, type = "bytes") > spec$max_bytes) return(list(ok = FALSE, error = "The file is larger than this kind allows."))
|
|
698
|
+
source <- paste0("file:", fname)
|
|
699
|
+
door <- "text"
|
|
700
|
+
} else {
|
|
701
|
+
return(list(ok = FALSE, error = "`from` must be a registry name, a URL, or a file's text."))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
# A shipped or system copy of that id is already the plugin: nothing to
|
|
705
|
+
# write, and the answer says so. (A user copy IS replaced — that is how a
|
|
706
|
+
# style is updated.)
|
|
707
|
+
rows <- plugin_rows(here, policy, kind)
|
|
708
|
+
have <- rows[rows$id == id & rows$active & rows$layer != "user", , drop = FALSE]
|
|
709
|
+
if (nrow(have)) {
|
|
710
|
+
return(list(ok = TRUE, kind = kind, id = id, name = have$name[[1L]], version = have$version[[1L]],
|
|
711
|
+
file = have$file[[1L]], requires = strsplit(have$requires[[1L]], ";")[[1L]], layer = have$layer[[1L]],
|
|
712
|
+
already = TRUE))
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
user_dir <- plugin_user_dir()
|
|
716
|
+
dir.create(file.path(user_dir, kind), recursive = TRUE, showWarnings = FALSE, mode = "0700")
|
|
717
|
+
stage <- plugin_stage_dir(user_dir)
|
|
718
|
+
on.exit(unlink(stage, recursive = TRUE, force = TRUE), add = TRUE)
|
|
719
|
+
file <- paste0(id, ext)
|
|
720
|
+
target_tmp <- file.path(stage, file)
|
|
721
|
+
if (identical(door, "text")) {
|
|
722
|
+
ok <- tryCatch({ writeLines(from$text, target_tmp, useBytes = TRUE, sep = ""); TRUE }, error = function(e) FALSE)
|
|
723
|
+
if (!ok) return(list(ok = FALSE, error = "Could not write the file."))
|
|
724
|
+
} else {
|
|
725
|
+
err <- plugin_fetch(policy, source, target_tmp, spec$max_bytes)
|
|
726
|
+
if (nzchar(err)) return(list(ok = FALSE, error = paste0("Could not fetch ", id, ": ", err)))
|
|
727
|
+
}
|
|
728
|
+
Sys.chmod(target_tmp, "0600")
|
|
729
|
+
v <- spec$validate(stage, file)
|
|
730
|
+
if (!isTRUE(v$ok)) return(list(ok = FALSE, error = paste0(id, " ", v$error, ".")))
|
|
731
|
+
|
|
732
|
+
# What it renders through, completed from the registry — once, before
|
|
733
|
+
# this one lands, so a plugin never sits installed with a missing parent.
|
|
734
|
+
requires <- as.character(v$requires %||% character(0))
|
|
735
|
+
for (req in requires) { # one parent at most in practice
|
|
736
|
+
if (!is.null(plugin_active(here, policy, kind, req))) next
|
|
737
|
+
if (!isTRUE(complete) || is.null(spec$registry)) {
|
|
738
|
+
return(list(ok = FALSE, error = paste0(id, " renders through ", req, ", which is not on this machine.")))
|
|
739
|
+
}
|
|
740
|
+
r <- plugin_install(here, policy, kind, list(registry = req), complete = FALSE)
|
|
741
|
+
if (!isTRUE(r$ok)) return(list(ok = FALSE, error = paste0("Could not complete ", id, ": ", r$error)))
|
|
742
|
+
if (length(r$requires)) {
|
|
743
|
+
plugin_remove(here, policy, kind, req)
|
|
744
|
+
return(list(ok = FALSE, error = paste0(req, " is not an independent plugin; ", id, " was not installed.")))
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
manifest <- plugin_manifest_for(stage, kind, id, file, v, source, spec$license)
|
|
749
|
+
plugin_manifest_write(stage, manifest)
|
|
750
|
+
Sys.chmod(file.path(stage, PLUGIN_MANIFEST), "0600")
|
|
751
|
+
|
|
752
|
+
# Into place, atomically: the staged folder is renamed onto the id. An
|
|
753
|
+
# existing user copy is moved aside first and removed after, so at no
|
|
754
|
+
# instant is there half a plugin at that path.
|
|
755
|
+
dest <- plugin_contained(file.path(user_dir, kind), id)
|
|
756
|
+
if (is.null(dest)) return(list(ok = FALSE, error = "The plugin's folder would fall outside the user layer."))
|
|
757
|
+
old <- NULL
|
|
758
|
+
if (dir.exists(dest)) {
|
|
759
|
+
old <- tempfile("plugin-old-", tmpdir = file.path(user_dir, ".staging"))
|
|
760
|
+
if (!file.rename(dest, old)) return(list(ok = FALSE, error = "Could not replace the existing copy."))
|
|
761
|
+
}
|
|
762
|
+
if (!file.rename(stage, dest)) {
|
|
763
|
+
if (!is.null(old)) file.rename(old, dest)
|
|
764
|
+
return(list(ok = FALSE, error = "Could not move the plugin into place."))
|
|
765
|
+
}
|
|
766
|
+
if (!is.null(old)) unlink(old, recursive = TRUE, force = TRUE)
|
|
767
|
+
on.exit(NULL) # staged folder is now the plugin; nothing to clean
|
|
768
|
+
list(ok = TRUE, kind = kind, id = id, name = manifest$name, version = manifest$version,
|
|
769
|
+
file = file, requires = requires, layer = "user", already = FALSE)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
#' Remove one plugin from the user layer. A shipped or system plugin is not
|
|
773
|
+
#' the person's to remove; a plugin another user plugin renders through
|
|
774
|
+
#' stays until that one goes.
|
|
775
|
+
plugin_remove <- function(here, policy, kind, id) {
|
|
776
|
+
if (!plugin_kind_ok(kind) || !plugin_id_ok(id)) return(list(ok = FALSE, error = "That is not a plugin."))
|
|
777
|
+
if (!identical(policy$install, "user")) return(list(ok = FALSE, error = plugin_policy_reason(policy, "install")))
|
|
778
|
+
rows <- plugin_rows(here, policy, kind)
|
|
779
|
+
mine <- rows[rows$id == id & rows$layer == "user", , drop = FALSE]
|
|
780
|
+
if (!nrow(mine)) {
|
|
781
|
+
if (any(rows$id == id)) return(list(ok = FALSE, error = "That plugin is part of this deployment; it cannot be removed here."))
|
|
782
|
+
return(list(ok = FALSE, error = "No such plugin."))
|
|
783
|
+
}
|
|
784
|
+
needs <- rows[rows$layer == "user" & rows$id != id &
|
|
785
|
+
vapply(strsplit(rows$requires, ";"), function(r) id %in% r, logical(1)), , drop = FALSE]
|
|
786
|
+
if (nrow(needs)) return(list(ok = FALSE, error = paste0(needs$name[[1L]], " renders through it; remove that one first.")))
|
|
787
|
+
dest <- plugin_contained(file.path(plugin_user_dir(), kind), id)
|
|
788
|
+
if (is.null(dest)) return(list(ok = FALSE, error = "That plugin's folder is outside the user layer."))
|
|
789
|
+
unlink(dest, recursive = TRUE, force = TRUE)
|
|
790
|
+
list(ok = TRUE, kind = kind, id = id)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
#' What the policy and the layers are, for the page: disclosure, never hidden.
|
|
794
|
+
plugin_policy_disclosure <- function(here, policy) {
|
|
795
|
+
layers <- plugin_layers(here, policy)
|
|
796
|
+
list(
|
|
797
|
+
set = isTRUE(policy$set),
|
|
798
|
+
source = if (identical(policy$source, "env")) "environment" else if (nzchar(policy$source)) "file" else "",
|
|
799
|
+
install = policy$install,
|
|
800
|
+
network = isTRUE(policy$network),
|
|
801
|
+
sources = I(as.character(policy$sources)),
|
|
802
|
+
kinds = I(as.character(policy$kinds)),
|
|
803
|
+
note = policy$note,
|
|
804
|
+
layers = I(lapply(layers, function(l) list(name = l$name, dir = l$dir, writable = isTRUE(l$writable), exists = dir.exists(l$dir)))),
|
|
805
|
+
kind_info = lapply(policy$kinds, function(k) list(
|
|
806
|
+
kind = k, label = PLUGIN_KINDS[[k]]$label, consumer = PLUGIN_KINDS[[k]]$consumer,
|
|
807
|
+
extension = PLUGIN_KINDS[[k]]$extension, max_bytes = PLUGIN_KINDS[[k]]$max_bytes,
|
|
808
|
+
registry = !is.null(PLUGIN_KINDS[[k]]$registry))) |> setNames(policy$kinds))
|
|
809
|
+
}
|