@crewhaus/tool-money 0.7.0 → 0.8.0

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/dist/paths.js CHANGED
@@ -11,15 +11,45 @@
11
11
  import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { CrewhausError } from "@crewhaus/errors";
14
- /** Raised when a caller-supplied path resolves outside the workspace root. */
14
+ /**
15
+ * The longest path this resolves, in UTF-16 code units; a longer one is
16
+ * refused before it is walked. Nothing that long can be opened or created:
17
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
18
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
19
+ * UTF-16 one. The limit is twice that because the part of a path that exists
20
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
21
+ * own: no spelling of the place reaches it, not even one through a symlink
22
+ * that shortens the part that exists.
23
+ */
24
+ export const MAX_PATH_CHARS = 2 * (process.platform === "darwin" ? 1_024 : process.platform === "win32" ? 32_767 : 4_096);
25
+ /** A path as a refusal quotes it: whole, unless it is past {@link MAX_PATH_CHARS}. */
26
+ function shownPath(given) {
27
+ return given.length > MAX_PATH_CHARS ? `${given.slice(0, 64)}…` : given;
28
+ }
29
+ /** Why a path past {@link MAX_PATH_CHARS} is refused: its length and the limit. */
30
+ function tooLong(abs) {
31
+ return `it is ${abs.length} characters once resolved, over the ${MAX_PATH_CHARS}-character limit: no path that long can be opened or created`;
32
+ }
33
+ /**
34
+ * Raised when a caller-supplied path resolves outside the workspace root, or
35
+ * is longer than any path can be.
36
+ */
15
37
  export class ToolPermissionError extends CrewhausError {
16
38
  name = "ToolPermissionError";
17
39
  toolName;
18
40
  path;
19
- constructor(toolName, attemptedPath) {
20
- super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
41
+ /**
42
+ * Why, when it is not that the location escapes the workspace root: a path
43
+ * longer than any path can be. A caller that rewords the refusal says this.
44
+ */
45
+ reason;
46
+ constructor(toolName, attemptedPath, reason) {
47
+ super("tool", reason === undefined
48
+ ? `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`
49
+ : `tool "${toolName}" rejected path "${shownPath(attemptedPath)}": ${reason}`);
21
50
  this.toolName = toolName;
22
51
  this.path = attemptedPath;
52
+ this.reason = reason;
23
53
  }
24
54
  }
25
55
  /** The workspace root, resolved. Every path is contained within it. */
@@ -67,15 +97,7 @@ function nameExists(p) {
67
97
  function resolveLocation(target, depth = 0) {
68
98
  if (depth > 40)
69
99
  throw new Error(`symlink chain at "${target}" is too long to resolve`);
70
- let probe = target;
71
- const tail = [];
72
- while (!nameExists(probe)) {
73
- tail.unshift(path.basename(probe));
74
- const parent = path.dirname(probe);
75
- if (parent === probe)
76
- break; // reached the filesystem root
77
- probe = parent;
78
- }
100
+ const { probe, tail } = deepestName(target);
79
101
  let probeReal;
80
102
  try {
81
103
  probeReal = realpathSync(probe);
@@ -97,7 +119,41 @@ function resolveLocation(target, depth = 0) {
97
119
  const base = realpathSync(path.dirname(probe));
98
120
  probeReal = resolveLocation(path.resolve(base, link), depth + 1);
99
121
  }
100
- return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
122
+ // One string, not one argument per name: Bun's `path.join` keeps only
123
+ // (argument count mod 65,536) of its arguments.
124
+ return tail.length > 0 ? path.join(probeReal, tail) : probeReal;
125
+ }
126
+ /**
127
+ * `target`, an absolute path, split where its deepest prefix that exists as
128
+ * a NAME ends: that prefix, and the names below it, which do not exist, as
129
+ * one string ("" when `target` itself exists). This is `path.dirname` and
130
+ * `path.basename` worked by index on `target` itself: handing them each
131
+ * shorter prefix in turn copied the rest of the path once per missing name,
132
+ * the square of a count the caller picks.
133
+ */
134
+ function deepestName(target) {
135
+ const top = path.parse(target).root.length;
136
+ const isSep = (i) => target[i] === "/" || target[i] === path.sep;
137
+ let probe = target;
138
+ let start = target.length;
139
+ let stop = -1;
140
+ while (!nameExists(probe)) {
141
+ // `path.basename(probe)` is `target.slice(start, end)`, and
142
+ // `path.dirname(probe)` is `target.slice(0, up)`.
143
+ let end = probe.length;
144
+ while (end > top && isSep(end - 1))
145
+ end--;
146
+ if (stop < 0)
147
+ stop = end;
148
+ start = end;
149
+ while (start > top && !isSep(start - 1))
150
+ start--;
151
+ const up = start > top ? start - 1 : top;
152
+ if (up === probe.length)
153
+ break; // reached the filesystem root
154
+ probe = target.slice(0, up);
155
+ }
156
+ return { probe, tail: stop < 0 ? "" : target.slice(start, stop) };
101
157
  }
102
158
  export function resolveSafe(toolName, rel, root = workspaceRoot()) {
103
159
  const rootResolved = path.resolve(root);
@@ -107,6 +163,9 @@ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
107
163
  if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
108
164
  throw new ToolPermissionError(toolName, rel);
109
165
  }
166
+ // Longer than any path can be: refused before the walk below reads it.
167
+ if (abs.length > MAX_PATH_CHARS)
168
+ throw new ToolPermissionError(toolName, rel, tooLong(abs));
110
169
  let real;
111
170
  try {
112
171
  const rootReal = realpathSync(rootResolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-money",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic money tools: payment identifier checksums, tax, refund allocation, three-way match, cost basis, spend controls and statement parsing",
6
6
  "main": "dist/index.js",
@@ -8,17 +8,19 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "dependencies": {
18
- "@crewhaus/errors": "0.7.0",
19
- "@crewhaus/tool-builder": "0.7.0",
20
- "@crewhaus/tool-catalog": "0.7.0",
21
- "@crewhaus/tool-schema": "0.7.0",
19
+ "@crewhaus/errors": "0.8.0",
20
+ "@crewhaus/tool-builder": "0.8.0",
21
+ "@crewhaus/tool-catalog": "0.8.0",
22
+ "@crewhaus/tool-safety": "0.8.0",
23
+ "@crewhaus/tool-schema": "0.8.0",
22
24
  "zod": "^3.23.8"
23
25
  },
24
26
  "devDependencies": {
@@ -42,5 +44,8 @@
42
44
  "publishConfig": {
43
45
  "access": "public"
44
46
  },
45
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
47
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
48
+ "engines": {
49
+ "bun": ">=1.3.14"
50
+ }
46
51
  }