@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 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 path-based. A symlink *inside* the scope root
267
- that points outside it would be followed by the OS on read/write. Do not place
268
- untrusted symlinks inside the scope root. (See *Deferred improvements*.)
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.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
- "test": "node --check runtime/admin.js && node --check resources/project-files/plugin.js && echo \"syntax OK\""
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), ctx.side);
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), ctx.side);
393
+ this._applyWidth(ctx.container, Math.min(ctx.width, this._widths(ctx).narrowW));
394
394
  return true;
395
395
  },
396
396
 
397
- _separatorWidth: function () {
398
- var sep = document.getElementById("red-ui-sidebar-separator");
399
- return sep ? (sep.offsetWidth || 7) : 7;
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
- // Model-agnostic: size the sidebar container itself. Set width AND
406
- // flex-basis so it works whether the sidebar is a block (pre-v5,
407
- // absolutely positioned) or a flex child (Node-RED 5 panel layout).
408
- container.style.width = width + "px";
409
- container.style.flexBasis = width + "px";
410
-
411
- // pre-v5 ONLY (capability-gated): the workspace + editor stack are
412
- // absolutely positioned and offset from the sidebar's side; they do
413
- // NOT reflow when the sidebar width changes, so the offset on the SAME
414
- // side the sidebar occupies must follow it. Node-RED 5's flex layout
415
- // reflows on its own, so we detect position:absolute and skip it
416
- // there. Side-aware (left left offset, right → right offset).
417
- var sepW = this._separatorWidth();
418
- ["red-ui-workspace", "red-ui-editor-stack"].forEach(function (id) {
419
- var elm = document.getElementById(id);
420
- if (!elm) return;
421
- var cs = window.getComputedStyle ? window.getComputedStyle(elm) : null;
422
- if (!cs || cs.position !== "absolute") return; // flex model → leave alone
423
- if (side === "left") elm.style.left = (width + sepW) + "px";
424
- else elm.style.right = (width + sepW) + "px";
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() !== savedText;
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
- if (mode === "project") {
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", "fa fa-exclamation-triangle");
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
- $ctxDownload.toggle(item.type === "file");
1136
- var prot = isProtected(item);
1137
- $ctxRename.toggleClass("dsff-ctx-disabled", prot)
1138
- .attr("title", prot ? "This item is protected and cannot be renamed or moved" : null);
1139
- $ctxDelete.toggleClass("dsff-ctx-disabled", prot)
1140
- .attr("title", prot ? "This file is protected and cannot be deleted" : null);
1141
-
1142
- var isReqFile = item.type === "file" && item.name === "requirements.txt";
1143
- $ctxInstallPy.toggle(isReqFile);
1144
- if (isReqFile) {
1145
- $ctxInstallPy.toggleClass("dsff-ctx-disabled", !hasVenv)
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
- // Root-level package.json only. Nested package.json files are ignored.
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
- // Read fresh on every request so switching/creating/closing a project at
44
- // runtime (no NR restart) takes effect immediately. We never simulate a
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
- function activeProjectName() {
47
- try { return (RED.settings.get("projects") || {}).activeProject || null; }
48
- catch (e) { return null; }
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 (!within(newAbs, scope.boundary)) return res.status(400).json({ error: OUT_OF_SCOPE });
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);
@@ -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 };