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
package/kernel/fileio.R
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
# fileio.R — the file system, in one place, for BOTH processes.
|
|
2
|
+
#
|
|
3
|
+
# Sourced by serve.R (the supervisor) and by worker.R. Every function here is
|
|
4
|
+
# PURE in the sense that matters for the split: it takes a request id and
|
|
5
|
+
# arguments, touches the file system, and RETURNS the frame to send — it never
|
|
6
|
+
# emits. The worker wraps each one in `emit(fs_x(...))`; the supervisor sends
|
|
7
|
+
# the returned frame to the socket that asked. One body, two callers, so the
|
|
8
|
+
# two processes cannot drift on what a path may be.
|
|
9
|
+
#
|
|
10
|
+
# Why the supervisor answers these at all (2026-09-09, docs/document-model-plan.md
|
|
11
|
+
# D3): in the worker they queued behind a running cell — a Save waited for a
|
|
12
|
+
# 40-second fit — and the document model that follows (the kernel holding the
|
|
13
|
+
# open documents) cannot sit behind evaluation. The supervisor evaluates no
|
|
14
|
+
# user code; listing a folder and writing bytes is not evaluation.
|
|
15
|
+
#
|
|
16
|
+
# `wd`: the WORKER owns the working directory (setwd() in a chunk moves it),
|
|
17
|
+
# so a relative path is resolved against the wd the caller passes — the worker
|
|
18
|
+
# passes getwd(), the supervisor passes the cwd the worker last reported. Every
|
|
19
|
+
# path is made absolute FIRST (fs_abs), then confined (within_root), then used.
|
|
20
|
+
|
|
21
|
+
#' Make a path absolute against `wd`, expanding `~`. An absolute path is
|
|
22
|
+
#' returned expanded and unchanged.
|
|
23
|
+
fs_abs <- function(p, wd = getwd()) {
|
|
24
|
+
p <- path.expand(p)
|
|
25
|
+
if (grepl("^(/|[A-Za-z]:[/\\]|\\\\)", p)) return(p)
|
|
26
|
+
file.path(wd, p)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# ── the confinement root ────────────────────────────────────────────────────
|
|
30
|
+
# CARMAR_ROOT restricts every FILE command — browse, read, write, import, and
|
|
31
|
+
# changing the working directory — to one subtree. It exists for managed
|
|
32
|
+
# deployments where a notebook should see the project share and nothing else.
|
|
33
|
+
#
|
|
34
|
+
# It is a GUARDRAIL, not a sandbox, and the difference must be stated plainly
|
|
35
|
+
# rather than implied: this session evaluates arbitrary R, so a determined user
|
|
36
|
+
# can call file.remove() on anything their account can reach and no in-process
|
|
37
|
+
# check can stop them. What the root does buy is real all the same — it stops
|
|
38
|
+
# the UI (and a mis-click, a stray path, a shared notebook someone else wrote)
|
|
39
|
+
# from wandering out of the project, and it makes "the notebook only touches
|
|
40
|
+
# /srv/projects/x" an enforceable default rather than a hope. Containment is
|
|
41
|
+
# the operating system's job: run the kernel as a user who cannot read the
|
|
42
|
+
# rest, or in a container.
|
|
43
|
+
confine_root <- local({
|
|
44
|
+
raw <- Sys.getenv("CARMAR_ROOT", "")
|
|
45
|
+
if (!nzchar(raw)) NULL
|
|
46
|
+
else normalizePath(path.expand(raw), mustWork = FALSE)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
#' Canonicalise a path that may not exist yet.
|
|
50
|
+
#'
|
|
51
|
+
#' `normalizePath()` resolves symlinks by ASKING THE FILE SYSTEM, so it can
|
|
52
|
+
#' only do it for a path that is already there; handed a path that does not
|
|
53
|
+
#' exist it returns the string unchanged. That is fatal for a confinement
|
|
54
|
+
#' check on a CREATE: on macOS `/var` is a symlink to `/private/var`, so a
|
|
55
|
+
#' root under `tempdir()` canonicalises to `/private/var/...` while the
|
|
56
|
+
#' not-yet-created file inside it stays `/var/...`, and the prefix comparison
|
|
57
|
+
#' says "outside" about a path that is plainly inside.
|
|
58
|
+
#'
|
|
59
|
+
#' So: normalise the deepest ancestor that DOES exist, then re-append the
|
|
60
|
+
#' components below it. The existing part gets real symlink resolution; the
|
|
61
|
+
#' part that does not exist yet cannot contain a symlink, because there is
|
|
62
|
+
#' nothing there to be one.
|
|
63
|
+
#'
|
|
64
|
+
#' @param p A path, absolute or relative.
|
|
65
|
+
#' @return The canonical absolute path.
|
|
66
|
+
canonical_path <- function(p) {
|
|
67
|
+
full <- path.expand(p)
|
|
68
|
+
if (file.exists(full)) return(normalizePath(full, mustWork = FALSE))
|
|
69
|
+
parts <- character(0)
|
|
70
|
+
at <- full
|
|
71
|
+
repeat {
|
|
72
|
+
up <- dirname(at)
|
|
73
|
+
parts <- c(basename(at), parts)
|
|
74
|
+
if (identical(up, at)) break # reached the filesystem root
|
|
75
|
+
if (file.exists(up)) {
|
|
76
|
+
return(do.call(file.path, as.list(c(normalizePath(up, mustWork = FALSE), parts))))
|
|
77
|
+
}
|
|
78
|
+
at <- up
|
|
79
|
+
}
|
|
80
|
+
normalizePath(full, mustWork = FALSE)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#' Is `p` inside the confinement root (when there is one)?
|
|
84
|
+
#'
|
|
85
|
+
#' Compared after canonicalisation, so `..` and symlinks are resolved before
|
|
86
|
+
#' the comparison rather than after — string-prefix checks on un-normalised
|
|
87
|
+
#' paths are how confinement bugs happen. The trailing separator matters too:
|
|
88
|
+
#' without it, /srv/project would also admit /srv/project-secrets.
|
|
89
|
+
within_root <- function(p) {
|
|
90
|
+
if (is.null(confine_root)) return(TRUE)
|
|
91
|
+
if (!is.character(p) || length(p) != 1L || is.na(p)) return(FALSE)
|
|
92
|
+
full <- canonical_path(p)
|
|
93
|
+
identical(full, confine_root) ||
|
|
94
|
+
startsWith(full, paste0(confine_root, .Platform$file.sep))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#' The standard refusal, so every file command says the same thing.
|
|
98
|
+
outside_root_msg <- function() {
|
|
99
|
+
sprintf("outside the permitted folder (%s)", confine_root)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#' List a directory for the Files pane.
|
|
103
|
+
#'
|
|
104
|
+
#' Directories first because that is how every file browser reads. A bad path
|
|
105
|
+
#' answers with an `error` FIELD: the pane holding a stale bookmark must see
|
|
106
|
+
#' "not a directory", not take the worker down.
|
|
107
|
+
#'
|
|
108
|
+
#' @param id Request id.
|
|
109
|
+
#' @param path Directory to list; NULL or "" means the working directory. `~`
|
|
110
|
+
#' is expanded.
|
|
111
|
+
#' @param all TRUE also lists dotfiles (never `.` / `..`). Off by default —
|
|
112
|
+
#' the same default `list.files()` has — and a client-side toggle.
|
|
113
|
+
#' @return Invisibly NULL. Emits one `files` frame.
|
|
114
|
+
fs_files <- function(id, path = NULL, all = FALSE, wd = getwd()) {
|
|
115
|
+
p <- if (is.null(path) || !is.character(path) || !nzchar(path[1L])) wd
|
|
116
|
+
else fs_abs(path[1L], wd)
|
|
117
|
+
p <- normalizePath(p, mustWork = FALSE)
|
|
118
|
+
if (!within_root(p)) {
|
|
119
|
+
return(list(type = "files", id = id, path = p, error = outside_root_msg()))
|
|
120
|
+
return(invisible(NULL))
|
|
121
|
+
}
|
|
122
|
+
if (!dir.exists(p)) {
|
|
123
|
+
return(list(type = "files", id = id, path = p, error = "not a directory"))
|
|
124
|
+
return(invisible(NULL))
|
|
125
|
+
}
|
|
126
|
+
if (file.access(p, mode = 4L) != 0L) {
|
|
127
|
+
return(list(type = "files", id = id, path = p, error = "not readable"))
|
|
128
|
+
return(invisible(NULL))
|
|
129
|
+
}
|
|
130
|
+
nms <- list.files(p, all.files = isTRUE(all), no.. = TRUE)
|
|
131
|
+
info <- file.info(file.path(p, nms))
|
|
132
|
+
isdir <- info$isdir %in% TRUE # a broken symlink reports NA
|
|
133
|
+
ord <- order(!isdir, tolower(nms))
|
|
134
|
+
entries <- lapply(ord, function(i) list(
|
|
135
|
+
name = nms[i], size = as.numeric(info$size[i]),
|
|
136
|
+
mtime = as.numeric(info$mtime[i]), isdir = isdir[i]))
|
|
137
|
+
return(list(type = "files", id = id, path = p, parent = dirname(p),
|
|
138
|
+
entries = entries))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#' Create a folder — the file tree's New Folder.
|
|
142
|
+
#'
|
|
143
|
+
#' `recursive = FALSE` on purpose: the tree only ever creates a child of a
|
|
144
|
+
#' folder it is already showing, so a missing intermediate means the tree is
|
|
145
|
+
#' stale, and silently manufacturing the whole chain would hide that. Refuses
|
|
146
|
+
#' an existing name rather than succeeding quietly, because "New Folder" that
|
|
147
|
+
#' returns ok on a name already taken reads as "created" and is not.
|
|
148
|
+
#'
|
|
149
|
+
#' @param id Request id.
|
|
150
|
+
#' @param path Absolute path of the folder to create.
|
|
151
|
+
#' @return Invisibly NULL. Emits one `mkdir` frame with `path` or `error`.
|
|
152
|
+
fs_mkdir <- function(id, path = NULL, wd = getwd()) {
|
|
153
|
+
fail <- function(msg) {
|
|
154
|
+
return(list(type = "mkdir", id = id, path = path, error = msg))
|
|
155
|
+
invisible(NULL)
|
|
156
|
+
}
|
|
157
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
158
|
+
p <- fs_abs(path, wd)
|
|
159
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
160
|
+
if (file.exists(p)) return(fail("something with that name is already here"))
|
|
161
|
+
if (!dir.exists(dirname(p))) return(fail(paste0("no such folder: ", dirname(p))))
|
|
162
|
+
ok <- tryCatch(dir.create(p, recursive = FALSE), warning = function(w) FALSE,
|
|
163
|
+
error = function(e) FALSE)
|
|
164
|
+
if (!isTRUE(ok)) return(fail("the folder could not be created"))
|
|
165
|
+
return(list(type = "mkdir", id = id, path = normalizePath(p, mustWork = FALSE)))
|
|
166
|
+
invisible(NULL)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#' Rename or move one entry — the tree's inline rename.
|
|
170
|
+
#'
|
|
171
|
+
#' BOTH ends are checked against the root. Checking only the source would let
|
|
172
|
+
#' a rename carry a file OUT of the permitted folder, which is the same
|
|
173
|
+
#' capability the root exists to deny; a guard that reads one end of a
|
|
174
|
+
#' two-ended operation is not a guard.
|
|
175
|
+
#'
|
|
176
|
+
#' Refuses to clobber. An overwrite here is indistinguishable from a typo, and
|
|
177
|
+
#' the tree has no undo.
|
|
178
|
+
#'
|
|
179
|
+
#' @param id Request id.
|
|
180
|
+
#' @param path Existing absolute path.
|
|
181
|
+
#' @param to Absolute destination path.
|
|
182
|
+
#' @return Invisibly NULL. Emits one `renamepath` frame with `from`/`to`, or `error`.
|
|
183
|
+
fs_renamepath <- function(id, path = NULL, to = NULL, wd = getwd()) {
|
|
184
|
+
fail <- function(msg) {
|
|
185
|
+
return(list(type = "renamepath", id = id, path = path, error = msg))
|
|
186
|
+
invisible(NULL)
|
|
187
|
+
}
|
|
188
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
189
|
+
if (!is.character(to) || length(to) != 1L || !nzchar(to)) return(fail("no new name"))
|
|
190
|
+
from <- fs_abs(path, wd)
|
|
191
|
+
dest <- fs_abs(to, wd)
|
|
192
|
+
if (!within_root(from) || !within_root(dest)) return(fail(outside_root_msg()))
|
|
193
|
+
if (!file.exists(from)) return(fail("that file is no longer here"))
|
|
194
|
+
if (file.exists(dest)) return(fail("something with that name is already here"))
|
|
195
|
+
if (!dir.exists(dirname(dest))) return(fail(paste0("no such folder: ", dirname(dest))))
|
|
196
|
+
ok <- tryCatch(file.rename(from, dest), warning = function(w) FALSE,
|
|
197
|
+
error = function(e) FALSE)
|
|
198
|
+
if (!isTRUE(ok)) return(fail("the rename failed"))
|
|
199
|
+
return(list(type = "renamepath", id = id,
|
|
200
|
+
from = normalizePath(from, mustWork = FALSE),
|
|
201
|
+
to = normalizePath(dest, mustWork = FALSE)))
|
|
202
|
+
invisible(NULL)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#' Delete files and folders — the tree's Delete.
|
|
206
|
+
#'
|
|
207
|
+
#' Takes a VECTOR, because the tree deletes a selection and N separate round
|
|
208
|
+
#' trips would leave a half-deleted selection on any failure. Each path is
|
|
209
|
+
#' root-checked individually; one refusal does not cancel the others, and the
|
|
210
|
+
#' frame reports exactly what went and what did not, so the tree never has to
|
|
211
|
+
#' guess which rows to drop.
|
|
212
|
+
#'
|
|
213
|
+
#' A non-empty folder needs `recursive = TRUE` — asked for explicitly by the
|
|
214
|
+
#' caller, which is the client that showed the confirmation naming the folder.
|
|
215
|
+
#'
|
|
216
|
+
#' @param id Request id.
|
|
217
|
+
#' @param paths Character vector of absolute paths.
|
|
218
|
+
#' @param recursive TRUE to remove non-empty folders.
|
|
219
|
+
#' @return Invisibly NULL. Emits one `deletepath` frame: `deleted`, `failed`.
|
|
220
|
+
fs_deletepath <- function(id, paths = NULL, recursive = FALSE, wd = getwd()) {
|
|
221
|
+
if (!is.character(paths) || !length(paths)) {
|
|
222
|
+
return(list(type = "deletepath", id = id, error = "no paths"))
|
|
223
|
+
return(invisible(NULL))
|
|
224
|
+
}
|
|
225
|
+
deleted <- character(0)
|
|
226
|
+
failed <- list()
|
|
227
|
+
note <- function(p, why) failed[[length(failed) + 1L]] <<- list(path = p, error = why)
|
|
228
|
+
for (raw in paths) {
|
|
229
|
+
if (!is.character(raw) || is.na(raw) || !nzchar(raw)) { note(raw, "no path"); next }
|
|
230
|
+
p <- fs_abs(raw, wd)
|
|
231
|
+
if (!within_root(p)) { note(p, outside_root_msg()); next }
|
|
232
|
+
if (!file.exists(p)) { note(p, "already gone"); next }
|
|
233
|
+
is_dir <- dir.exists(p)
|
|
234
|
+
if (is_dir && !isTRUE(recursive) && length(list.files(p, all.files = TRUE, no.. = TRUE))) {
|
|
235
|
+
note(p, "the folder is not empty")
|
|
236
|
+
next
|
|
237
|
+
}
|
|
238
|
+
ok <- tryCatch(unlink(p, recursive = is_dir, force = FALSE) == 0L,
|
|
239
|
+
warning = function(w) FALSE, error = function(e) FALSE)
|
|
240
|
+
# unlink() reports 0 for "nothing to do" as well as success, so the file
|
|
241
|
+
# system is asked again rather than trusting the status code.
|
|
242
|
+
if (isTRUE(ok) && !file.exists(p)) deleted <- c(deleted, p) else note(p, "could not be deleted")
|
|
243
|
+
}
|
|
244
|
+
return(list(type = "deletepath", id = id, deleted = I(as.list(deleted)),
|
|
245
|
+
failed = I(failed)))
|
|
246
|
+
invisible(NULL)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#' Copy a file or folder — the tree's Duplicate.
|
|
250
|
+
#'
|
|
251
|
+
#' Both ends root-checked, for the same reason rename checks both: a copy is a
|
|
252
|
+
#' two-ended operation, and a guard that reads one end lets data OUT.
|
|
253
|
+
#'
|
|
254
|
+
#' Refuses to clobber. `file.copy(overwrite = FALSE)` returns FALSE rather than
|
|
255
|
+
#' erroring on a collision, so the check is explicit and the message says which
|
|
256
|
+
#' of the two things went wrong.
|
|
257
|
+
#'
|
|
258
|
+
#' @param id Request id.
|
|
259
|
+
#' @param path Existing absolute path.
|
|
260
|
+
#' @param to Absolute destination path.
|
|
261
|
+
#' @return Invisibly NULL. Emits one `copypath` frame with `from`/`to`, or `error`.
|
|
262
|
+
fs_copypath <- function(id, path = NULL, to = NULL, wd = getwd()) {
|
|
263
|
+
fail <- function(msg) {
|
|
264
|
+
return(list(type = "copypath", id = id, path = path, error = msg))
|
|
265
|
+
invisible(NULL)
|
|
266
|
+
}
|
|
267
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
268
|
+
if (!is.character(to) || length(to) != 1L || !nzchar(to)) return(fail("no destination"))
|
|
269
|
+
from <- fs_abs(path, wd)
|
|
270
|
+
dest <- fs_abs(to, wd)
|
|
271
|
+
if (!within_root(from) || !within_root(dest)) return(fail(outside_root_msg()))
|
|
272
|
+
if (!file.exists(from)) return(fail("that file is no longer here"))
|
|
273
|
+
if (file.exists(dest)) return(fail("something with that name is already here"))
|
|
274
|
+
if (!dir.exists(dirname(dest))) return(fail(paste0("no such folder: ", dirname(dest))))
|
|
275
|
+
ok <- tryCatch({
|
|
276
|
+
if (dir.exists(from)) {
|
|
277
|
+
# file.copy(recursive=) copies the DIRECTORY INTO the target, so the
|
|
278
|
+
# destination must exist first and is named by its parent, not itself.
|
|
279
|
+
dir.create(dest, recursive = FALSE)
|
|
280
|
+
kids <- list.files(from, all.files = TRUE, no.. = TRUE, full.names = TRUE)
|
|
281
|
+
all(file.copy(kids, dest, recursive = TRUE, copy.date = TRUE))
|
|
282
|
+
} else {
|
|
283
|
+
file.copy(from, dest, overwrite = FALSE, copy.date = TRUE)
|
|
284
|
+
}
|
|
285
|
+
}, warning = function(w) FALSE, error = function(e) FALSE)
|
|
286
|
+
if (!isTRUE(ok) || !file.exists(dest)) return(fail("the copy failed"))
|
|
287
|
+
return(list(type = "copypath", id = id,
|
|
288
|
+
from = normalizePath(from, mustWork = FALSE),
|
|
289
|
+
to = normalizePath(dest, mustWork = FALSE)))
|
|
290
|
+
invisible(NULL)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#' Show one entry in the desktop file manager.
|
|
294
|
+
#'
|
|
295
|
+
#' The path is root-checked and handed to the platform opener as an ARGUMENT
|
|
296
|
+
#' VECTOR, never interpolated into a shell string: a file called
|
|
297
|
+
#' `; rm -rf ~` is a legal filename, and `system()` on a composed string would
|
|
298
|
+
#' run it. `system2()` with a character vector does not go through a shell.
|
|
299
|
+
#'
|
|
300
|
+
#' @param id Request id.
|
|
301
|
+
#' @param path Absolute path to reveal.
|
|
302
|
+
#' @return Invisibly NULL. Emits one `revealpath` frame.
|
|
303
|
+
fs_revealpath <- function(id, path = NULL, wd = getwd()) {
|
|
304
|
+
fail <- function(msg) {
|
|
305
|
+
return(list(type = "revealpath", id = id, path = path, error = msg))
|
|
306
|
+
invisible(NULL)
|
|
307
|
+
}
|
|
308
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
309
|
+
p <- fs_abs(path, wd)
|
|
310
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
311
|
+
if (!file.exists(p)) return(fail("that file is no longer here"))
|
|
312
|
+
p <- normalizePath(p, mustWork = TRUE)
|
|
313
|
+
sysname <- unname(Sys.info()[["sysname"]])
|
|
314
|
+
status <- tryCatch(switch(sysname,
|
|
315
|
+
Darwin = system2("/usr/bin/open", c("-R", p), stdout = FALSE, stderr = FALSE),
|
|
316
|
+
Windows = system2("explorer", sprintf("/select,%s", p), stdout = FALSE, stderr = FALSE),
|
|
317
|
+
system2("xdg-open", dirname(p), stdout = FALSE, stderr = FALSE)),
|
|
318
|
+
warning = function(w) 0L, error = function(e) 1L)
|
|
319
|
+
# Windows explorer returns 1 on success; only a hard failure is reported.
|
|
320
|
+
if (!identical(sysname, "Windows") && !identical(as.integer(status), 0L)) {
|
|
321
|
+
return(fail("the file manager could not be opened"))
|
|
322
|
+
}
|
|
323
|
+
return(list(type = "revealpath", id = id, path = p))
|
|
324
|
+
invisible(NULL)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#' Read a text file — the script editor's Open.
|
|
328
|
+
#'
|
|
329
|
+
#' Text only, and capped: the editor is for .R scripts, and handing a 400 MB
|
|
330
|
+
#' CSV to a textarea is not an editing session, it is a hung tab. A binary
|
|
331
|
+
#' file is refused by the NUL byte rather than rendered as mojibake.
|
|
332
|
+
#'
|
|
333
|
+
#' @param id Request id.
|
|
334
|
+
#' @param path File to read; `~` is expanded.
|
|
335
|
+
#' @return Invisibly NULL. Emits one `readfile` frame with `text`, or `error`.
|
|
336
|
+
MAX_TEXT_BYTES <- 4e6
|
|
337
|
+
|
|
338
|
+
#' Decode file bytes into the ONE text the page edits, and say how.
|
|
339
|
+
#'
|
|
340
|
+
#' Until 7.18 every byte that was not valid UTF-8 was silently dropped between
|
|
341
|
+
#' `rawToChar` and the JSON frame: a Latin-1 `.tex` (`\usepackage[latin1]
|
|
342
|
+
#' {inputenc}`, still common) opened as "Caf" for "Café" with no error, and a
|
|
343
|
+
#' save was then refused with the wrong message. The page edits UTF-8; the
|
|
344
|
+
#' file keeps its own encoding; this pair is the boundary. Only two encodings
|
|
345
|
+
#' are named because only two are decidable from bytes alone — valid UTF-8 is
|
|
346
|
+
#' UTF-8, and anything else is read as Latin-1, which every byte sequence is.
|
|
347
|
+
#'
|
|
348
|
+
#' @param raw_bytes Raw vector.
|
|
349
|
+
#' @return list(text = UTF-8 string, encoding = "utf-8" | "latin1"), or NULL
|
|
350
|
+
#' when the bytes cannot be made into a string at all.
|
|
351
|
+
text_from_bytes <- function(raw_bytes) {
|
|
352
|
+
text <- tryCatch(rawToChar(raw_bytes), error = function(e) NULL)
|
|
353
|
+
if (is.null(text)) return(NULL)
|
|
354
|
+
if (validUTF8(text)) {
|
|
355
|
+
Encoding(text) <- "UTF-8"
|
|
356
|
+
return(list(text = text, encoding = "utf-8"))
|
|
357
|
+
}
|
|
358
|
+
latin <- iconv(text, from = "latin1", to = "UTF-8")
|
|
359
|
+
if (is.na(latin)) return(NULL)
|
|
360
|
+
list(text = latin, encoding = "latin1")
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#' Encode the page's UTF-8 text back into the file's bytes.
|
|
364
|
+
#' @param text UTF-8 string.
|
|
365
|
+
#' @param encoding "utf-8" (default) or "latin1", as `text_from_bytes` reported.
|
|
366
|
+
#' @return Raw vector, or a character error message when `text` holds a
|
|
367
|
+
#' character the encoding cannot carry (a `€` in a Latin-1 file): the save
|
|
368
|
+
#' fails closed and says which, rather than writing a `?`.
|
|
369
|
+
bytes_from_text <- function(text, encoding = "utf-8") {
|
|
370
|
+
text <- enc2utf8(text)
|
|
371
|
+
if (identical(encoding, "latin1")) {
|
|
372
|
+
out <- iconv(text, from = "UTF-8", to = "latin1")
|
|
373
|
+
if (is.na(out)) {
|
|
374
|
+
return("the file is Latin-1 and the text now holds a character Latin-1 cannot store; use Save As to write it as UTF-8")
|
|
375
|
+
}
|
|
376
|
+
return(charToRaw(out))
|
|
377
|
+
}
|
|
378
|
+
if (!identical(encoding, "utf-8")) return(sprintf("unknown file encoding '%s'", encoding))
|
|
379
|
+
charToRaw(text)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
file_encoding_arg <- function(encoding) {
|
|
383
|
+
if (is.character(encoding) && length(encoding) == 1L && nzchar(encoding)) encoding else "utf-8"
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
fs_readfile <- function(id, path = NULL, wd = getwd()) {
|
|
387
|
+
fail <- function(msg) {
|
|
388
|
+
return(list(type = "readfile", id = id, path = path, error = msg))
|
|
389
|
+
invisible(NULL)
|
|
390
|
+
}
|
|
391
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
392
|
+
p <- fs_abs(path, wd)
|
|
393
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
394
|
+
if (!file.exists(p) || dir.exists(p)) return(fail("not a readable file"))
|
|
395
|
+
size <- file.info(p)$size
|
|
396
|
+
if (isTRUE(size > MAX_TEXT_BYTES)) {
|
|
397
|
+
return(fail(sprintf("too large to edit (%.1f MB)", size / 1e6)))
|
|
398
|
+
}
|
|
399
|
+
raw_bytes <- tryCatch(readBin(p, "raw", n = size),
|
|
400
|
+
error = function(e) conditionMessage(e))
|
|
401
|
+
if (is.character(raw_bytes)) return(fail(raw_bytes))
|
|
402
|
+
if (any(raw_bytes == as.raw(0L))) return(fail("not a text file"))
|
|
403
|
+
decoded <- text_from_bytes(raw_bytes)
|
|
404
|
+
if (is.null(decoded)) return(fail("could not decode as text"))
|
|
405
|
+
return(list(type = "readfile", id = id, path = normalizePath(p), text = decoded$text,
|
|
406
|
+
encoding = decoded$encoding))
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
#' Replace (or create) `p`: write a temp file beside it, then rename OVER the
|
|
410
|
+
#' target in one filesystem operation. Never move the original away first:
|
|
411
|
+
#' readers must see either the old file or the new one, not a missing path.
|
|
412
|
+
#' R uses rename(2) on POSIX and replacement-enabled MoveFileExW on Windows.
|
|
413
|
+
#' This is namespace atomicity on supported local filesystems, not an fsync /
|
|
414
|
+
#' power-loss durability guarantee. The mode of an existing file is kept.
|
|
415
|
+
#' Returns NULL on success or the sentence to report.
|
|
416
|
+
fs_replace_atomic <- function(p, body_bytes) {
|
|
417
|
+
temp <- tempfile(pattern = ".carmar-save-", tmpdir = dirname(p))
|
|
418
|
+
existed <- file.exists(p)
|
|
419
|
+
old_mode <- if (existed) file.info(p)$mode else NULL
|
|
420
|
+
cleanup_save <- function() {
|
|
421
|
+
if (file.exists(temp)) unlink(temp)
|
|
422
|
+
}
|
|
423
|
+
# Error replies are observable by the browser immediately. Clean first so a
|
|
424
|
+
# completed failure reply also means recovery has finished on disk.
|
|
425
|
+
fail_after_cleanup <- function(msg) {
|
|
426
|
+
cleanup_save()
|
|
427
|
+
msg
|
|
428
|
+
}
|
|
429
|
+
on.exit(cleanup_save(), add = TRUE)
|
|
430
|
+
ok <- tryCatch({
|
|
431
|
+
con <- file(temp, open = "wb")
|
|
432
|
+
writeBin(body_bytes, con)
|
|
433
|
+
close(con)
|
|
434
|
+
con <- NULL
|
|
435
|
+
if (!is.null(old_mode)) Sys.chmod(temp, mode = old_mode)
|
|
436
|
+
TRUE
|
|
437
|
+
}, error = function(e) conditionMessage(e),
|
|
438
|
+
finally = if (exists("con", inherits = FALSE) && !is.null(con)) try(close(con), silent = TRUE))
|
|
439
|
+
if (is.character(ok)) return(fail_after_cleanup(ok))
|
|
440
|
+
# Keep the existing test knob as an alias for the pre-replacement boundary.
|
|
441
|
+
# The original is still at its path; a failure needs no restoration rename.
|
|
442
|
+
if (identical(Sys.getenv("CARMAR_TEST_SAVE_FAILURE", ""), "after-backup")) {
|
|
443
|
+
return(fail_after_cleanup("simulated failure before replacement"))
|
|
444
|
+
}
|
|
445
|
+
replaced <- tryCatch(suppressWarnings(file.rename(temp, p)), error = function(e) FALSE)
|
|
446
|
+
if (!isTRUE(replaced)) {
|
|
447
|
+
return(fail_after_cleanup("could not atomically replace the file; the original was left unchanged"))
|
|
448
|
+
}
|
|
449
|
+
NULL
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#' Write a text file — the script editor's Save.
|
|
453
|
+
#'
|
|
454
|
+
#' Writes exactly the bytes given. The directory must already exist: a Save
|
|
455
|
+
#' that silently creates a tree is a Save that puts the file somewhere else than
|
|
456
|
+
#' the user believes. The replacement is a same-directory rename, and an
|
|
457
|
+
#' optional exact previous value makes an editor Save fail closed if another
|
|
458
|
+
#' program changed the file since it was opened.
|
|
459
|
+
#'
|
|
460
|
+
#' Binary travels as `base64`: the page's blob exports (xlsx, png, a plot)
|
|
461
|
+
#' had no way onto the disk from a webview with no download delegate, and R
|
|
462
|
+
#' is the one process here that can write a file where the person asked. The
|
|
463
|
+
#' decoded size is capped at the same MAX_TEXT_BYTES — 4 MB decoded is a
|
|
464
|
+
#' 5.4 MB frame, under the supervisor's 8 MB cap — and the exact-content
|
|
465
|
+
#' check is refused because it is defined over TEXT (re-encoded the way the
|
|
466
|
+
#' file was decoded); a binary file has no such previous value to compare.
|
|
467
|
+
#'
|
|
468
|
+
#' @param id Request id.
|
|
469
|
+
#' @param path Destination; `~` is expanded.
|
|
470
|
+
#' @param text Contents.
|
|
471
|
+
#' @param base64 Binary contents, base64-encoded; when given, `text` is ignored.
|
|
472
|
+
#' @return Invisibly NULL. Emits one `writefile` frame with `path`, or `error`.
|
|
473
|
+
fs_writefile <- function(id, path = NULL, text = "", expected = NULL, encoding = NULL,
|
|
474
|
+
base64 = NULL, wd = getwd()) {
|
|
475
|
+
fail <- function(msg) {
|
|
476
|
+
return(list(type = "writefile", id = id, path = path, error = msg))
|
|
477
|
+
invisible(NULL)
|
|
478
|
+
}
|
|
479
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
480
|
+
p <- fs_abs(path, wd)
|
|
481
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
482
|
+
if (dir.exists(p)) return(fail("that is a directory"))
|
|
483
|
+
if (!dir.exists(dirname(p))) return(fail(paste0("no such folder: ", dirname(p))))
|
|
484
|
+
binary <- is.character(base64) && length(base64) == 1L && nzchar(base64)
|
|
485
|
+
if (binary) {
|
|
486
|
+
if (!is.null(expected)) return(fail("the exact-content check applies to text saves"))
|
|
487
|
+
body_bytes <- tryCatch(jsonlite::base64_dec(base64), error = function(e) NULL)
|
|
488
|
+
if (!is.raw(body_bytes)) return(fail("not valid base64"))
|
|
489
|
+
if (length(body_bytes) > MAX_TEXT_BYTES) return(fail("too large to write through R"))
|
|
490
|
+
} else {
|
|
491
|
+
if (!is.null(text) && !is.character(text)) return(fail("text must be a string"))
|
|
492
|
+
if (is.character(text) && sum(nchar(text, type = "bytes")) > MAX_TEXT_BYTES) {
|
|
493
|
+
return(fail("too large to save from the editor"))
|
|
494
|
+
}
|
|
495
|
+
body <- if (is.character(text) && length(text)) paste(text, collapse = "\n") else ""
|
|
496
|
+
encoding <- file_encoding_arg(encoding)
|
|
497
|
+
body_bytes <- bytes_from_text(body, encoding)
|
|
498
|
+
if (is.character(body_bytes)) return(fail(body_bytes))
|
|
499
|
+
}
|
|
500
|
+
if (!is.null(expected)) {
|
|
501
|
+
if (!is.character(expected)) return(fail("expected text must be a string"))
|
|
502
|
+
before <- paste(expected, collapse = "\n")
|
|
503
|
+
if (!file.exists(p)) return(fail("the file was removed after it was opened; nothing was written"))
|
|
504
|
+
size <- file.info(p)$size
|
|
505
|
+
raw <- tryCatch(readBin(p, "raw", n = size), error = function(e) NULL)
|
|
506
|
+
# Compare BYTES to bytes: the page's expectation is re-encoded the way
|
|
507
|
+
# the file was decoded, so a Latin-1 file matches its own bytes.
|
|
508
|
+
before_bytes <- bytes_from_text(before, encoding)
|
|
509
|
+
if (is.null(raw) || is.character(before_bytes) || !identical(raw, before_bytes)) {
|
|
510
|
+
return(fail("the file changed on disk after it was opened; nothing was written"))
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
err <- fs_replace_atomic(p, body_bytes)
|
|
514
|
+
if (!is.null(err)) return(fail(err))
|
|
515
|
+
# Built field by field: a `encoding = NULL` inside list() is an element the
|
|
516
|
+
# supervisor's JSON would render as `{}`, and a binary file has no encoding.
|
|
517
|
+
reply <- list(type = "writefile", id = id, path = normalizePath(p), bytes = length(body_bytes))
|
|
518
|
+
if (binary) reply$binary <- TRUE else reply$encoding <- encoding
|
|
519
|
+
return(reply)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
#' Atomically replace several existing text files after exact-content checks.
|
|
523
|
+
#'
|
|
524
|
+
#' Every temporary and backup lives beside its target, so rename stays on one
|
|
525
|
+
#' filesystem. All preconditions are checked before the first rename; a later
|
|
526
|
+
#' failure restores every target already swapped.
|
|
527
|
+
fs_writefiles_atomic <- function(id, files = NULL, wd = getwd()) {
|
|
528
|
+
fail <- function(msg) {
|
|
529
|
+
return(list(type = "writefiles_atomic", id = id, error = msg))
|
|
530
|
+
invisible(NULL)
|
|
531
|
+
}
|
|
532
|
+
records <- if (is.data.frame(files)) {
|
|
533
|
+
lapply(seq_len(nrow(files)), function(i) as.list(files[i, , drop = FALSE]))
|
|
534
|
+
} else if (is.list(files)) files else list()
|
|
535
|
+
if (!length(records)) return(fail("no files"))
|
|
536
|
+
if (length(records) > 50L) return(fail("too many files in one change"))
|
|
537
|
+
|
|
538
|
+
plans <- list()
|
|
539
|
+
for (item in records) {
|
|
540
|
+
p <- if (is.list(item) && is.character(item$path) && length(item$path)) fs_abs(item$path[[1L]], wd) else ""
|
|
541
|
+
before <- if (is.list(item) && is.character(item$before)) paste(item$before, collapse = "\n") else NULL
|
|
542
|
+
after <- if (is.list(item) && is.character(item$after)) paste(item$after, collapse = "\n") else NULL
|
|
543
|
+
if (!nzchar(p) || is.null(before) || is.null(after)) return(fail("each file needs path, before and after"))
|
|
544
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
545
|
+
if (!file.exists(p) || dir.exists(p)) return(fail(paste0("not an existing text file: ", p)))
|
|
546
|
+
size <- file.info(p)$size
|
|
547
|
+
raw <- tryCatch(readBin(p, "raw", n = size), error = function(e) NULL)
|
|
548
|
+
# The file's own encoding decides the comparison and the write, exactly
|
|
549
|
+
# as emit_writefile does; a rename across a Latin-1 project stays exact.
|
|
550
|
+
encoding <- file_encoding_arg(if (is.list(item)) item$encoding else NULL)
|
|
551
|
+
before_bytes <- bytes_from_text(before, encoding)
|
|
552
|
+
after_bytes <- bytes_from_text(after, encoding)
|
|
553
|
+
if (is.character(after_bytes)) return(fail(paste0(basename(p), ": ", after_bytes)))
|
|
554
|
+
if (is.null(raw) || is.character(before_bytes) || !identical(raw, before_bytes)) {
|
|
555
|
+
return(fail(paste0(basename(p), " changed after the preview; nothing was written")))
|
|
556
|
+
}
|
|
557
|
+
if (length(after_bytes) > MAX_TEXT_BYTES) return(fail(paste0(basename(p), " is too large")))
|
|
558
|
+
plans[[length(plans) + 1L]] <- list(path = p, before = before, after = after, bytes = after_bytes,
|
|
559
|
+
mode = file.info(p)$mode,
|
|
560
|
+
temp = tempfile(pattern = ".carmar-new-", tmpdir = dirname(p)),
|
|
561
|
+
backup = tempfile(pattern = ".carmar-old-", tmpdir = dirname(p)))
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
cleanup <- function() {
|
|
565
|
+
for (plan in plans) {
|
|
566
|
+
if (file.exists(plan$temp)) unlink(plan$temp)
|
|
567
|
+
if (file.exists(plan$backup) && file.exists(plan$path)) unlink(plan$backup)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
on.exit(cleanup(), add = TRUE)
|
|
571
|
+
for (plan in plans) {
|
|
572
|
+
con <- NULL
|
|
573
|
+
ok <- tryCatch({
|
|
574
|
+
con <- file(plan$temp, open = "wb")
|
|
575
|
+
writeBin(plan$bytes, con)
|
|
576
|
+
close(con)
|
|
577
|
+
con <- NULL
|
|
578
|
+
Sys.chmod(plan$temp, mode = plan$mode)
|
|
579
|
+
TRUE
|
|
580
|
+
}, error = function(e) conditionMessage(e),
|
|
581
|
+
finally = if (!is.null(con)) try(close(con), silent = TRUE))
|
|
582
|
+
if (!identical(ok, TRUE)) return(fail(as.character(ok)))
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
swapped <- integer(0)
|
|
586
|
+
for (i in seq_along(plans)) {
|
|
587
|
+
plan <- plans[[i]]
|
|
588
|
+
ok <- file.rename(plan$path, plan$backup) && file.rename(plan$temp, plan$path)
|
|
589
|
+
if (!ok) {
|
|
590
|
+
if (!file.exists(plan$path) && file.exists(plan$backup)) file.rename(plan$backup, plan$path)
|
|
591
|
+
for (j in rev(swapped)) {
|
|
592
|
+
prior <- plans[[j]]
|
|
593
|
+
if (file.exists(prior$path)) unlink(prior$path)
|
|
594
|
+
if (file.exists(prior$backup)) file.rename(prior$backup, prior$path)
|
|
595
|
+
}
|
|
596
|
+
return(fail("could not replace every file; all completed replacements were restored"))
|
|
597
|
+
}
|
|
598
|
+
swapped <- c(swapped, i)
|
|
599
|
+
}
|
|
600
|
+
for (plan in plans) if (file.exists(plan$backup)) unlink(plan$backup)
|
|
601
|
+
return(list(type = "writefiles_atomic", id = id,
|
|
602
|
+
paths = I(vapply(plans, function(plan) normalizePath(plan$path), character(1)))))
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
#' Write raw bytes to `path` (absolute or relative to `wd`), atomically and
|
|
607
|
+
#' confined — the body of `PUT /files`. Returns the frame to report.
|
|
608
|
+
fs_write_raw <- function(id, path = NULL, bytes = raw(0), wd = getwd(), expected = NULL) {
|
|
609
|
+
fail <- function(msg) list(type = "writefile", id = id, path = path, error = msg)
|
|
610
|
+
if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
|
|
611
|
+
if (!is.raw(bytes)) return(fail("no bytes"))
|
|
612
|
+
if (!is.null(expected) && !is.raw(expected)) return(fail("expected content must be bytes"))
|
|
613
|
+
p <- fs_abs(path, wd)
|
|
614
|
+
if (!within_root(p)) return(fail(outside_root_msg()))
|
|
615
|
+
if (dir.exists(p)) return(fail("that is a directory"))
|
|
616
|
+
if (!dir.exists(dirname(p))) return(fail(paste0("no such folder: ", dirname(p))))
|
|
617
|
+
if (!is.null(expected)) {
|
|
618
|
+
if (!file.exists(p)) return(fail("the file was removed after it was opened; nothing was written"))
|
|
619
|
+
size <- file.info(p)$size
|
|
620
|
+
before <- if (is.finite(size) && size == length(expected))
|
|
621
|
+
tryCatch(readBin(p, "raw", n = size), error = function(e) NULL) else NULL
|
|
622
|
+
if (is.null(before) || !identical(before, expected)) {
|
|
623
|
+
return(fail("the file changed on disk after it was opened; nothing was written"))
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
err <- fs_replace_atomic(p, bytes)
|
|
627
|
+
if (!is.null(err)) return(fail(err))
|
|
628
|
+
list(type = "writefile", id = id, path = normalizePath(p), bytes = length(bytes), binary = TRUE)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
#' A Content-Type for `GET /files`, by extension; anything else is opaque.
|
|
632
|
+
fs_content_type <- function(path) {
|
|
633
|
+
base <- basename(path)
|
|
634
|
+
ext <- if (grepl("\\.", base)) tolower(sub("^.*\\.", "", base)) else ""
|
|
635
|
+
types <- c(png = "image/png", jpg = "image/jpeg", jpeg = "image/jpeg", gif = "image/gif",
|
|
636
|
+
svg = "image/svg+xml", pdf = "application/pdf", html = "text/html; charset=utf-8",
|
|
637
|
+
htm = "text/html; charset=utf-8", csv = "text/csv; charset=utf-8",
|
|
638
|
+
tsv = "text/tab-separated-values; charset=utf-8", txt = "text/plain; charset=utf-8",
|
|
639
|
+
md = "text/markdown; charset=utf-8", qmd = "text/markdown; charset=utf-8",
|
|
640
|
+
rmd = "text/markdown; charset=utf-8", carmd = "text/markdown; charset=utf-8",
|
|
641
|
+
r = "text/plain; charset=utf-8", tex = "application/x-tex", bib = "text/plain; charset=utf-8",
|
|
642
|
+
json = "application/json", xlsx = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
643
|
+
docx = "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
644
|
+
zip = "application/zip", rdata = "application/octet-stream", rds = "application/octet-stream")
|
|
645
|
+
hit <- if (nzchar(ext) && ext %in% names(types)) types[[ext]] else "application/octet-stream"
|
|
646
|
+
hit
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#' A file name safe inside a Content-Disposition header: ASCII, no quotes,
|
|
650
|
+
#' no control characters, never empty.
|
|
651
|
+
fs_disposition_name <- function(name) {
|
|
652
|
+
n <- iconv(as.character(name), to = "ASCII", sub = "_")
|
|
653
|
+
n <- gsub('[^A-Za-z0-9._ -]', "_", n)
|
|
654
|
+
n <- trimws(n)
|
|
655
|
+
if (!nzchar(n)) "download" else n
|
|
656
|
+
}
|