@inteli.city/node-red-plugin-project-files 1.0.0 → 1.0.1
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/README.md +51 -3
- package/package.json +7 -2
- package/resources/project-files/plugin.js +45 -51
- package/resources/project-files/rules.js +42 -0
- package/runtime/admin.js +17 -68
- package/runtime/scope.js +110 -0
package/README.md
CHANGED
|
@@ -263,9 +263,10 @@ development), the editor has full access, exactly as Node-RED does itself.
|
|
|
263
263
|
- **Text editor only.** Files are opened as UTF‑8 text; binary files are not
|
|
264
264
|
editable in the editor (they can still be uploaded, moved, downloaded, and
|
|
265
265
|
deleted).
|
|
266
|
-
- **Symlinks.** The scope check is
|
|
267
|
-
that
|
|
268
|
-
|
|
266
|
+
- **Symlinks.** The scope check is symlink-aware: a symlink *inside* the scope
|
|
267
|
+
root that resolves outside it is rejected on read, write and delete (the
|
|
268
|
+
deepest existing ancestor is canonicalised with `realpath` before the boundary
|
|
269
|
+
comparison). Symlinks that stay within the scope continue to work normally.
|
|
269
270
|
- **Single boundary per mode.** The plugin manages one tree at a time (the
|
|
270
271
|
active project, or the user directory); it is not a general, multi-root
|
|
271
272
|
filesystem browser. Dependency/Python tools exist only in Project Mode.
|
|
@@ -279,6 +280,53 @@ development), the editor has full access, exactly as Node-RED does itself.
|
|
|
279
280
|
|
|
280
281
|
---
|
|
281
282
|
|
|
283
|
+
## Testing
|
|
284
|
+
|
|
285
|
+
The suite uses Node's built-in test runner (`node:test`) — **no extra
|
|
286
|
+
dependencies, no global Node-RED, no network**. It needs Node 18+ (developed on
|
|
287
|
+
Node 22). Every test runs against temporary directories and cleans up after
|
|
288
|
+
itself; nothing is written outside the OS temp dir, and `npm`/`pip` are mocked
|
|
289
|
+
(no real package installs).
|
|
290
|
+
|
|
291
|
+
```bash
|
|
292
|
+
npm test # all layers
|
|
293
|
+
npm run test:unit # pure rules.js logic (no DOM, no Node-RED)
|
|
294
|
+
npm run test:frontend # mode banner / context menu / dirty-guard decisions
|
|
295
|
+
npm run test:backend # runtime/admin.js: filesystem boundary + modes + deps
|
|
296
|
+
npm run test:smoke # package consistency + load + end-to-end workflow
|
|
297
|
+
npm run syntax # quick `node --check` syntax pass
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Layers** (see [`tests/README.md`](./tests/README.md) for details):
|
|
301
|
+
|
|
302
|
+
| Layer | Location | Covers |
|
|
303
|
+
| -------------------- | ----------------- | ------ |
|
|
304
|
+
| Unit (rules) | `tests/unit` | Protected/read-only/language/move rules — pure, deterministic. |
|
|
305
|
+
| Frontend behavior | `tests/frontend` | Mode-banner visibility, context-menu state, unsaved-changes guard. |
|
|
306
|
+
| Backend | `tests/backend` | Scope boundary, traversal/symlink/absolute rejection, Project vs. User mode, mocked dependency commands. |
|
|
307
|
+
| Smoke / integration | `tests/smoke` | Package metadata ↔ files, plugin load, list→create→open→edit→save→restart. |
|
|
308
|
+
|
|
309
|
+
**Required before publishing (release-blocking):** the backend filesystem
|
|
310
|
+
**boundary** tests (`tests/backend/boundary.test.js`) and the **mode** tests
|
|
311
|
+
(`tests/backend/mode.test.js`). These prove no route can read, write, or delete
|
|
312
|
+
outside the active scope and that mode resolution is correct. Treat a failure
|
|
313
|
+
here as a release blocker.
|
|
314
|
+
|
|
315
|
+
**Mocked / isolated external behavior:**
|
|
316
|
+
|
|
317
|
+
- `child_process.spawn` is replaced in `tests/backend/deps.test.js`; **no real
|
|
318
|
+
`python`, `pip`, or `npm` runs**. Tests assert command intent (binary, args)
|
|
319
|
+
and the working directory only.
|
|
320
|
+
- The browser frontend (`plugin.js` DOM) is not instantiated; its critical
|
|
321
|
+
decision logic was extracted into `rules.js` and is tested there directly.
|
|
322
|
+
|
|
323
|
+
**Intentionally deferred:** a full Node-RED boot (pack → install → start → load
|
|
324
|
+
editor) is marked `skip` in `tests/smoke/plugin-load.test.js` and only runs with
|
|
325
|
+
`SMOKE_FULL=1`. It needs a heavy dependency and network, so it is opt-in/manual
|
|
326
|
+
for release validation rather than part of the default offline suite.
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
282
330
|
## Upgrading
|
|
283
331
|
|
|
284
332
|
Your data lives in your projects/user directory, **not** inside the package, so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inteli.city/node-red-plugin-project-files",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Node-RED sidebar plugin to browse and edit project files, manage Python virtual environments, and install Node packages — scoped safely to your projects directory.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
"node": ">=16.0.0"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
|
-
"
|
|
38
|
+
"syntax": "node --check runtime/admin.js && node --check resources/project-files/plugin.js && echo \"syntax OK\"",
|
|
39
|
+
"test": "node --test tests/*/*.test.js",
|
|
40
|
+
"test:unit": "node --test tests/unit/*.test.js",
|
|
41
|
+
"test:frontend": "node --test tests/frontend/*.test.js",
|
|
42
|
+
"test:backend": "node --test tests/backend/*.test.js",
|
|
43
|
+
"test:smoke": "node --test tests/smoke/*.test.js"
|
|
39
44
|
}
|
|
40
45
|
}
|
|
@@ -381,7 +381,7 @@ RED.plugins.registerPlugin("project-files", {
|
|
|
381
381
|
if (!ctx) return false;
|
|
382
382
|
// Grow to the wide target (never shrink if already wider). No state
|
|
383
383
|
// stored — the new width IS the state.
|
|
384
|
-
this._applyWidth(ctx.container, Math.max(ctx.width, this._widths(ctx).wideW)
|
|
384
|
+
this._applyWidth(ctx.container, Math.max(ctx.width, this._widths(ctx).wideW));
|
|
385
385
|
return true;
|
|
386
386
|
},
|
|
387
387
|
|
|
@@ -390,41 +390,38 @@ RED.plugins.registerPlugin("project-files", {
|
|
|
390
390
|
if (!ctx) return false;
|
|
391
391
|
// Shrink to the narrow target (never widen one that's already narrower).
|
|
392
392
|
// Works regardless of which plugin expanded it.
|
|
393
|
-
this._applyWidth(ctx.container, Math.min(ctx.width, this._widths(ctx).narrowW)
|
|
393
|
+
this._applyWidth(ctx.container, Math.min(ctx.width, this._widths(ctx).narrowW));
|
|
394
394
|
return true;
|
|
395
395
|
},
|
|
396
396
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
// The ONLY function that writes host layout. Guarded; never throws out.
|
|
403
|
-
_applyWidth: function (container, width, side) {
|
|
397
|
+
// The ONLY function that writes host layout. To keep Node-RED's internal
|
|
398
|
+
// layout consistent (so the node-edit tray — which sizes itself from
|
|
399
|
+
// #red-ui-editor-stack's geometry — respects the new width), we reproduce
|
|
400
|
+
// the EXACT settled state Node-RED's own separator-drag produces.
|
|
401
|
+
_applyWidth: function (container, width) {
|
|
404
402
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// Let Node-RED re-layout (tray.js handleWindowResize) if it listens.
|
|
403
|
+
var ws = document.getElementById("red-ui-workspace");
|
|
404
|
+
var es = document.getElementById("red-ui-editor-stack");
|
|
405
|
+
var absolute = !!(ws && window.getComputedStyle &&
|
|
406
|
+
window.getComputedStyle(ws).position === "absolute");
|
|
407
|
+
if (absolute && typeof $ !== "undefined") {
|
|
408
|
+
// Node-RED's absolute layout model (v4.x): set the sidebar width and
|
|
409
|
+
// the workspace / editor-stack / separator offsets to the same final
|
|
410
|
+
// values NR's drag leaves behind. Constants (width + 7 / + 8 / + 2)
|
|
411
|
+
// are taken verbatim from NR's sidebar source, so the tray, canvas
|
|
412
|
+
// and separator all line up exactly as after a manual drag.
|
|
413
|
+
$(container).width(width);
|
|
414
|
+
ws.style.right = (width + 7) + "px";
|
|
415
|
+
es.style.right = (width + 8) + "px";
|
|
416
|
+
var sep = document.getElementById("red-ui-sidebar-separator");
|
|
417
|
+
if (sep) { sep.style.left = "auto"; sep.style.right = (width + 2) + "px"; }
|
|
418
|
+
} else {
|
|
419
|
+
// Flex layout (Node-RED 5): size the sidebar; the workspace and
|
|
420
|
+
// editor stack reflow themselves, so the tray geometry stays correct
|
|
421
|
+
// without any manual offsets.
|
|
422
|
+
container.style.width = width + "px";
|
|
423
|
+
container.style.flexBasis = width + "px";
|
|
424
|
+
}
|
|
428
425
|
if (window.RED && RED.events && typeof RED.events.emit === "function") {
|
|
429
426
|
RED.events.emit("sidebar:resize");
|
|
430
427
|
}
|
|
@@ -682,7 +679,7 @@ RED.plugins.registerPlugin("project-files", {
|
|
|
682
679
|
// savedText. The dirty flag is only for UI; never trust it alone for guards.
|
|
683
680
|
function isDirty() {
|
|
684
681
|
if (!currentFile) return false;
|
|
685
|
-
return getEditorContent()
|
|
682
|
+
return needsDirtyConfirm(currentFile, getEditorContent(), savedText);
|
|
686
683
|
}
|
|
687
684
|
|
|
688
685
|
function syncDirty() {
|
|
@@ -841,12 +838,13 @@ RED.plugins.registerPlugin("project-files", {
|
|
|
841
838
|
// the fallback (User Directory) Mode, where behavior differs from the normal
|
|
842
839
|
// project-based workflow.
|
|
843
840
|
function updateModeBar() {
|
|
844
|
-
|
|
841
|
+
var banner = modeBanner(mode);
|
|
842
|
+
if (!banner.show) {
|
|
845
843
|
$modeBar.hide();
|
|
846
844
|
return;
|
|
847
845
|
}
|
|
848
846
|
$modeBar.addClass("dsff-mode-user").show();
|
|
849
|
-
$modeIcon.attr("class",
|
|
847
|
+
$modeIcon.attr("class", banner.icon);
|
|
850
848
|
$modeText.empty().append(
|
|
851
849
|
$("<strong>").text("User Directory Mode"),
|
|
852
850
|
document.createTextNode(
|
|
@@ -1132,24 +1130,20 @@ RED.plugins.registerPlugin("project-files", {
|
|
|
1132
1130
|
hideCtxMenu();
|
|
1133
1131
|
ctxTarget = item;
|
|
1134
1132
|
$ctxLabel.text(item.name);
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
$ctxRename.toggleClass("dsff-ctx-disabled",
|
|
1138
|
-
.attr("title",
|
|
1139
|
-
$ctxDelete.toggleClass("dsff-ctx-disabled",
|
|
1140
|
-
.attr("title",
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
.attr("title", hasVenv ? null : "Create a Python environment first");
|
|
1133
|
+
var st = ctxMenuState(item, { hasVenv: hasVenv, projectRoot: projectRoot });
|
|
1134
|
+
$ctxDownload.toggle(st.showDownload);
|
|
1135
|
+
$ctxRename.toggleClass("dsff-ctx-disabled", st.renameDisabled)
|
|
1136
|
+
.attr("title", st.renameDisabled ? "This item is protected and cannot be renamed or moved" : null);
|
|
1137
|
+
$ctxDelete.toggleClass("dsff-ctx-disabled", st.deleteDisabled)
|
|
1138
|
+
.attr("title", st.deleteDisabled ? "This file is protected and cannot be deleted" : null);
|
|
1139
|
+
|
|
1140
|
+
$ctxInstallPy.toggle(st.showInstallPy);
|
|
1141
|
+
if (st.showInstallPy) {
|
|
1142
|
+
$ctxInstallPy.toggleClass("dsff-ctx-disabled", st.installPyDisabled)
|
|
1143
|
+
.attr("title", st.installPyDisabled ? "Create a Python environment first" : null);
|
|
1147
1144
|
}
|
|
1148
1145
|
|
|
1149
|
-
|
|
1150
|
-
var isRootPkg = item.type === "file" && item.name === "package.json" &&
|
|
1151
|
-
!!projectRoot && item.path === projectRoot + "/package.json";
|
|
1152
|
-
$ctxInstallNode.toggle(isRootPkg);
|
|
1146
|
+
$ctxInstallNode.toggle(st.showInstallNode);
|
|
1153
1147
|
|
|
1154
1148
|
var x = ev.clientX, y = ev.clientY;
|
|
1155
1149
|
$ctxMenu.css({ top: 0, left: 0 }).show();
|
|
@@ -44,4 +44,46 @@
|
|
|
44
44
|
window.canMove = function (item) {
|
|
45
45
|
return !window.isProtected(item);
|
|
46
46
|
};
|
|
47
|
+
|
|
48
|
+
// ── UI decision logic (pure; rendered by plugin.js) ───────────────────────
|
|
49
|
+
// Operating-mode banner. Project Mode is the normal state and shows NO banner
|
|
50
|
+
// (the active project stays visible in the header). User Directory Mode shows
|
|
51
|
+
// a persistent warning bar. Returns what to render, not the DOM itself.
|
|
52
|
+
window.modeBanner = function (mode) {
|
|
53
|
+
if (mode === "project") return { show: false, kind: "project" };
|
|
54
|
+
return { show: true, kind: "user", icon: "fa fa-exclamation-triangle" };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Project-only features (Python venv, Node package install) require an active
|
|
58
|
+
// project — they are unavailable in User Directory Mode.
|
|
59
|
+
window.projectFeaturesAvailable = function (mode) {
|
|
60
|
+
return mode === "project";
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Context-menu item state for a tree item, given the current venv/project
|
|
64
|
+
// context. ctx: { hasVenv: bool, projectRoot: string|null }. Mirrors the
|
|
65
|
+
// backend's protection rules so the menu never offers a forbidden action.
|
|
66
|
+
window.ctxMenuState = function (item, ctx) {
|
|
67
|
+
ctx = ctx || {};
|
|
68
|
+
var prot = window.isProtected(item);
|
|
69
|
+
var isReqFile = item.type === "file" && item.name === "requirements.txt";
|
|
70
|
+
// Root-level package.json only; nested package.json files are ignored.
|
|
71
|
+
var isRootPkg = item.type === "file" && item.name === "package.json" &&
|
|
72
|
+
!!ctx.projectRoot && item.path === ctx.projectRoot + "/package.json";
|
|
73
|
+
return {
|
|
74
|
+
showDownload: item.type === "file",
|
|
75
|
+
renameDisabled: prot,
|
|
76
|
+
deleteDisabled: prot,
|
|
77
|
+
showInstallPy: isReqFile,
|
|
78
|
+
installPyDisabled: isReqFile ? !ctx.hasVenv : false,
|
|
79
|
+
showInstallNode: isRootPkg,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Whether abandoning the current file needs an "unsaved changes" confirm.
|
|
84
|
+
// Authoritative check: live editor content vs. the last saved baseline.
|
|
85
|
+
window.needsDirtyConfirm = function (currentFile, content, savedText) {
|
|
86
|
+
if (!currentFile) return false;
|
|
87
|
+
return content !== savedText;
|
|
88
|
+
};
|
|
47
89
|
})();
|
package/runtime/admin.js
CHANGED
|
@@ -2,6 +2,7 @@ module.exports = function (RED) {
|
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const fsp = require("fs").promises;
|
|
5
|
+
const { createScope, withinSafe, safeErr } = require("./scope");
|
|
5
6
|
|
|
6
7
|
// ── Config ────────────────────────────────────────────────────────────────
|
|
7
8
|
// The plugin runs in one of two explicit operating modes, decided per request
|
|
@@ -39,14 +40,22 @@ module.exports = function (RED) {
|
|
|
39
40
|
try { fs.mkdirSync(PROJECTS_DIR, { recursive: true }); }
|
|
40
41
|
catch (e) { RED.log.warn(`[project-files] could not create projects dir: ${e.code || e.message}`); }
|
|
41
42
|
|
|
42
|
-
// ── Operating-mode resolution
|
|
43
|
-
//
|
|
44
|
-
//
|
|
43
|
+
// ── Operating-mode resolution + path safety ───────────────────────────────
|
|
44
|
+
// The filesystem boundary and scope resolution live in ./scope.js — the single
|
|
45
|
+
// auditable security core. We inject the live "active project" read here: it is
|
|
46
|
+
// evaluated FRESH per request so switching/creating/closing a project at
|
|
47
|
+
// runtime (no NR restart) takes effect immediately, and we never simulate a
|
|
45
48
|
// project when none is active — the absence of one *is* User Directory Mode.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
const { resolveScope, resolveInScope, toRel } = createScope({
|
|
50
|
+
userDir: USER_DIR,
|
|
51
|
+
projectsDir: PROJECTS_DIR,
|
|
52
|
+
getActiveProject() {
|
|
53
|
+
try { return (RED.settings.get("projects") || {}).activeProject || null; }
|
|
54
|
+
catch (e) { return null; }
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Whether Node-RED Projects is enabled (messaging only — never a boundary input).
|
|
50
59
|
function projectsEnabled() {
|
|
51
60
|
try {
|
|
52
61
|
const et = RED.settings.editorTheme;
|
|
@@ -55,66 +64,6 @@ module.exports = function (RED) {
|
|
|
55
64
|
return null; // undetermined — treated as "not enabled" for messaging only
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
// Returns the scope for the current request:
|
|
59
|
-
// mode — "project" | "user"
|
|
60
|
-
// project — active project name (Project Mode) or null
|
|
61
|
-
// anchor — directory that relative paths are resolved/encoded against
|
|
62
|
-
// boundary — the authoritative security boundary; nothing may escape it
|
|
63
|
-
function resolveScope() {
|
|
64
|
-
const active = activeProjectName();
|
|
65
|
-
if (active) {
|
|
66
|
-
return {
|
|
67
|
-
mode: "project",
|
|
68
|
-
project: active,
|
|
69
|
-
anchor: PROJECTS_DIR,
|
|
70
|
-
boundary: path.join(PROJECTS_DIR, active),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
return {
|
|
74
|
-
mode: "user",
|
|
75
|
-
project: null,
|
|
76
|
-
anchor: USER_DIR,
|
|
77
|
-
boundary: USER_DIR,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Sandbox check — every request goes through this. `abs` must already be an
|
|
82
|
-
// absolute, resolved path (callers use path.resolve(scope.anchor, …)).
|
|
83
|
-
// Rejects anything outside the active boundary, including ../ traversal and
|
|
84
|
-
// sibling directories that merely share a name prefix.
|
|
85
|
-
function within(abs, boundary) {
|
|
86
|
-
const n = path.normalize(abs);
|
|
87
|
-
const b = path.normalize(boundary);
|
|
88
|
-
return n === b || n.startsWith(b + path.sep);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Resolve a client-supplied, anchor-relative path and enforce the boundary in
|
|
92
|
-
// one step. Returns the absolute path, or null if it escapes the boundary.
|
|
93
|
-
function resolveInScope(scope, rel) {
|
|
94
|
-
const abs = path.resolve(scope.anchor, rel || ".");
|
|
95
|
-
return within(abs, scope.boundary) ? abs : null;
|
|
96
|
-
}
|
|
97
|
-
// Encode an absolute path back to an anchor-relative, forward-slash path.
|
|
98
|
-
function toRel(scope, abs) {
|
|
99
|
-
return path.relative(scope.anchor, abs).replace(/\\/g, "/");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Map a thrown error to a generic, path-free message safe to return to the
|
|
103
|
-
// client, and log the real error server-side for operators. This keeps
|
|
104
|
-
// absolute filesystem paths and internal structure out of HTTP responses.
|
|
105
|
-
function safeErr(e) {
|
|
106
|
-
switch (e && e.code) {
|
|
107
|
-
case "ENOENT": return "File or folder not found";
|
|
108
|
-
case "EACCES":
|
|
109
|
-
case "EPERM": return "Permission denied";
|
|
110
|
-
case "EEXIST": return "A file or folder with that name already exists";
|
|
111
|
-
case "ENOTDIR": return "Path is not a directory";
|
|
112
|
-
case "EISDIR": return "Path is a directory";
|
|
113
|
-
case "ENOSPC": return "No space left on device";
|
|
114
|
-
case "EROFS": return "Filesystem is read-only";
|
|
115
|
-
default: return "Operation failed";
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
67
|
function logErr(where, e) {
|
|
119
68
|
RED.log.warn(`[project-files] ${where}: ${(e && (e.stack || e.message)) || e}`);
|
|
120
69
|
}
|
|
@@ -293,7 +242,7 @@ module.exports = function (RED) {
|
|
|
293
242
|
const abs = resolveInScope(scope, rel);
|
|
294
243
|
if (!abs) return res.status(400).json({ error: OUT_OF_SCOPE });
|
|
295
244
|
const newAbs = path.join(path.dirname(abs), name);
|
|
296
|
-
if (!
|
|
245
|
+
if (!withinSafe(newAbs, scope.boundary)) return res.status(400).json({ error: OUT_OF_SCOPE });
|
|
297
246
|
if (PROTECTED_FILES.has(path.basename(abs))) return res.status(403).json({ error: "This file is protected and cannot be renamed." });
|
|
298
247
|
if (await trystat(newAbs)) return res.status(400).json({ error: "Name already exists" });
|
|
299
248
|
await fsp.rename(abs, newAbs);
|
package/runtime/scope.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// scope.js — Filesystem security boundary + operating-mode resolution.
|
|
2
|
+
//
|
|
3
|
+
// This is the security core of the plugin: every client-supplied path is
|
|
4
|
+
// resolved and bounds-checked here before any fs access happens in admin.js.
|
|
5
|
+
// It is deliberately self-contained and free of Node-RED dependencies — the one
|
|
6
|
+
// live input (the active project name) is injected as a callback — so the whole
|
|
7
|
+
// boundary can be read, audited, and unit-tested in one place, in isolation.
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
|
|
13
|
+
// Lexical containment — is `abs` the boundary itself, or strictly inside it?
|
|
14
|
+
// `abs` must already be absolute and resolved. Rejects ../ traversal and
|
|
15
|
+
// sibling directories that merely share a name prefix.
|
|
16
|
+
function within(abs, boundary) {
|
|
17
|
+
const n = path.normalize(abs);
|
|
18
|
+
const b = path.normalize(boundary);
|
|
19
|
+
return n === b || n.startsWith(b + path.sep);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Symlink-safe containment. `within()` is purely lexical, so a symlink that
|
|
23
|
+
// LEXICALLY sits inside the boundary but points outside it would be followed on
|
|
24
|
+
// read/write and escape the sandbox. We canonicalise the deepest existing
|
|
25
|
+
// ancestor of `abs` (symlinks resolved), re-append the not-yet-existing tail
|
|
26
|
+
// (those segments can't be symlinks because they don't exist), and compare
|
|
27
|
+
// against the real boundary. The tail handling lets create/save/rename target
|
|
28
|
+
// paths that don't exist yet still be checked correctly.
|
|
29
|
+
function realResolve(abs) {
|
|
30
|
+
let cur = path.normalize(abs);
|
|
31
|
+
const tail = [];
|
|
32
|
+
for (;;) {
|
|
33
|
+
try {
|
|
34
|
+
const real = fs.realpathSync(cur);
|
|
35
|
+
return tail.length ? path.join(real, ...tail.reverse()) : real;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
const parent = path.dirname(cur);
|
|
38
|
+
if (parent === cur) return path.normalize(abs); // reached FS root; nothing resolved
|
|
39
|
+
tail.push(path.basename(cur));
|
|
40
|
+
cur = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function realBoundary(boundary) {
|
|
45
|
+
try { return fs.realpathSync(boundary); } catch (e) { return path.normalize(boundary); }
|
|
46
|
+
}
|
|
47
|
+
function withinSafe(abs, boundary) {
|
|
48
|
+
return within(realResolve(abs), realBoundary(boundary));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Map a thrown fs error to a generic, path-free message safe to return to the
|
|
52
|
+
// client. Keeps absolute filesystem paths and internal structure out of HTTP
|
|
53
|
+
// responses; the caller logs the real error server-side.
|
|
54
|
+
function safeErr(e) {
|
|
55
|
+
switch (e && e.code) {
|
|
56
|
+
case "ENOENT": return "File or folder not found";
|
|
57
|
+
case "EACCES":
|
|
58
|
+
case "EPERM": return "Permission denied";
|
|
59
|
+
case "EEXIST": return "A file or folder with that name already exists";
|
|
60
|
+
case "ENOTDIR": return "Path is not a directory";
|
|
61
|
+
case "EISDIR": return "Path is a directory";
|
|
62
|
+
case "ENOSPC": return "No space left on device";
|
|
63
|
+
case "EROFS": return "Filesystem is read-only";
|
|
64
|
+
default: return "Operation failed";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Build the scope resolver for one plugin instance. Inputs are explicit:
|
|
69
|
+
// userDir — absolute Node-RED user directory (User Directory Mode boundary)
|
|
70
|
+
// projectsDir — absolute projects base (Project Mode anchor)
|
|
71
|
+
// getActiveProject — () => active project name | null, read FRESH per request so
|
|
72
|
+
// switching/creating/closing a project at runtime takes effect
|
|
73
|
+
// immediately. We never simulate a project when none is active.
|
|
74
|
+
//
|
|
75
|
+
// resolveScope() returns, for the current request:
|
|
76
|
+
// mode — "project" | "user"
|
|
77
|
+
// project — active project name (Project Mode) or null
|
|
78
|
+
// anchor — directory that relative paths are resolved/encoded against
|
|
79
|
+
// boundary — the authoritative security boundary; nothing may escape it
|
|
80
|
+
function createScope({ userDir, projectsDir, getActiveProject }) {
|
|
81
|
+
function resolveScope() {
|
|
82
|
+
const active = getActiveProject();
|
|
83
|
+
if (active) {
|
|
84
|
+
return {
|
|
85
|
+
mode: "project",
|
|
86
|
+
project: active,
|
|
87
|
+
anchor: projectsDir,
|
|
88
|
+
boundary: path.join(projectsDir, active),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { mode: "user", project: null, anchor: userDir, boundary: userDir };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Resolve a client-supplied, anchor-relative path and enforce the boundary in
|
|
95
|
+
// one step. Returns the absolute path, or null if it escapes the boundary
|
|
96
|
+
// (directly, via ../ traversal, or through a symlink that points outside).
|
|
97
|
+
function resolveInScope(scope, rel) {
|
|
98
|
+
const abs = path.resolve(scope.anchor, rel || ".");
|
|
99
|
+
return withinSafe(abs, scope.boundary) ? abs : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Encode an absolute path back to an anchor-relative, forward-slash path.
|
|
103
|
+
function toRel(scope, abs) {
|
|
104
|
+
return path.relative(scope.anchor, abs).replace(/\\/g, "/");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { resolveScope, resolveInScope, toRel };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { createScope, within, withinSafe, realResolve, safeErr };
|