@crewhaus/tool-money 0.7.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/README.md +97 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +455 -0
- package/dist/lib/allocate.d.ts +70 -0
- package/dist/lib/allocate.js +145 -0
- package/dist/lib/coding.d.ts +50 -0
- package/dist/lib/coding.js +80 -0
- package/dist/lib/controls.d.ts +91 -0
- package/dist/lib/controls.js +174 -0
- package/dist/lib/identifiers.d.ts +46 -0
- package/dist/lib/identifiers.js +303 -0
- package/dist/lib/lots.d.ts +60 -0
- package/dist/lib/lots.js +142 -0
- package/dist/lib/match.d.ts +58 -0
- package/dist/lib/match.js +136 -0
- package/dist/lib/statement.d.ts +80 -0
- package/dist/lib/statement.js +296 -0
- package/dist/lib/tax.d.ts +79 -0
- package/dist/lib/tax.js +139 -0
- package/dist/lib/webhook.d.ts +30 -0
- package/dist/lib/webhook.js +116 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +46 -0
package/dist/lib/tax.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-line tax from an operator's own rate tables.
|
|
3
|
+
*
|
|
4
|
+
* Money is integer minor units throughout — cents, pence, satoshi-free. A
|
|
5
|
+
* tax engine that computes in floating point produces totals that do not add
|
|
6
|
+
* up, and "off by a cent" in an invoice is not a rounding detail, it is a
|
|
7
|
+
* document that fails an audit.
|
|
8
|
+
*
|
|
9
|
+
* Two choices are the caller's and neither has a safe default, so both are
|
|
10
|
+
* explicit: whether listed prices already include tax, and whether rounding
|
|
11
|
+
* happens per line or once per invoice. Jurisdictions differ, the totals
|
|
12
|
+
* differ, and a tool that picked one silently would be wrong half the time.
|
|
13
|
+
*/
|
|
14
|
+
export const ROUNDING_MODES = ["half-up", "half-even", "down", "up"];
|
|
15
|
+
export const ROUNDING_SCOPES = ["line", "invoice"];
|
|
16
|
+
/** Round a rational to an integer, by the caller's rule. */
|
|
17
|
+
export function roundMinor(numerator, denominator, mode) {
|
|
18
|
+
if (denominator === 0)
|
|
19
|
+
throw new Error("denominator must not be zero");
|
|
20
|
+
const negative = numerator < 0 !== denominator < 0;
|
|
21
|
+
const n = Math.abs(numerator);
|
|
22
|
+
const d = Math.abs(denominator);
|
|
23
|
+
const whole = Math.floor(n / d);
|
|
24
|
+
const remainder = n - whole * d;
|
|
25
|
+
let result;
|
|
26
|
+
switch (mode) {
|
|
27
|
+
case "down":
|
|
28
|
+
result = whole;
|
|
29
|
+
break;
|
|
30
|
+
case "up":
|
|
31
|
+
result = remainder > 0 ? whole + 1 : whole;
|
|
32
|
+
break;
|
|
33
|
+
case "half-even": {
|
|
34
|
+
const twice = remainder * 2;
|
|
35
|
+
if (twice > d)
|
|
36
|
+
result = whole + 1;
|
|
37
|
+
else if (twice < d)
|
|
38
|
+
result = whole;
|
|
39
|
+
// Exactly half: go to the even neighbour. Over many lines this is what
|
|
40
|
+
// keeps a total from drifting upward the way half-up does.
|
|
41
|
+
else
|
|
42
|
+
result = whole % 2 === 0 ? whole : whole + 1;
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
result = remainder * 2 >= d ? whole + 1 : whole;
|
|
47
|
+
}
|
|
48
|
+
return negative ? -result : result;
|
|
49
|
+
}
|
|
50
|
+
const BPS_DENOMINATOR = 10_000;
|
|
51
|
+
export function calculateTax(lines, rates, options = {}) {
|
|
52
|
+
const mode = options.rounding ?? "half-up";
|
|
53
|
+
const scope = options.scope ?? "line";
|
|
54
|
+
const byCodeIndex = new Map(rates.map((r) => [r.code, r]));
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
for (const code of line.taxCodes) {
|
|
57
|
+
if (!byCodeIndex.has(code)) {
|
|
58
|
+
throw new Error(`line "${line.id}" uses tax code "${code}", which is not in the rate table — add it rather than letting the line go untaxed`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!Number.isInteger(lines.reduce((s, l) => s + l.amountMinor, 0))) {
|
|
63
|
+
throw new Error("amounts must be integer minor units");
|
|
64
|
+
}
|
|
65
|
+
const tally = new Map();
|
|
66
|
+
const results = [];
|
|
67
|
+
// Exact totals kept as numerator over BPS_DENOMINATOR, so invoice-scope
|
|
68
|
+
// rounding rounds the true sum rather than a sum of rounded pieces.
|
|
69
|
+
let exactTaxNumerator = 0;
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if (!Number.isInteger(line.amountMinor)) {
|
|
72
|
+
throw new Error(`line "${line.id}" has a non-integer amount; use minor units`);
|
|
73
|
+
}
|
|
74
|
+
const applied = line.exempt || options.reverseCharge ? [] : line.taxCodes;
|
|
75
|
+
const rateList = applied.map((code) => byCodeIndex.get(code));
|
|
76
|
+
// Net is the base everything is charged on. When listed prices already
|
|
77
|
+
// include tax it has to be recovered first: gross / (1 + total rate).
|
|
78
|
+
let netMinor;
|
|
79
|
+
if (options.pricesIncludeTax && rateList.length > 0) {
|
|
80
|
+
let multiplier = BPS_DENOMINATOR;
|
|
81
|
+
for (const rate of rateList) {
|
|
82
|
+
multiplier = rate.compound
|
|
83
|
+
? Math.round((multiplier * (BPS_DENOMINATOR + rate.bps)) / BPS_DENOMINATOR)
|
|
84
|
+
: multiplier + rate.bps;
|
|
85
|
+
}
|
|
86
|
+
netMinor = roundMinor(line.amountMinor * BPS_DENOMINATOR, multiplier, mode);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
netMinor = line.amountMinor;
|
|
90
|
+
}
|
|
91
|
+
const breakdown = [];
|
|
92
|
+
let lineTax = 0;
|
|
93
|
+
for (const rate of rateList) {
|
|
94
|
+
// `compound` describes the base THIS rate is charged on — net plus the
|
|
95
|
+
// taxes already applied — not an effect on the rates after it. Adding
|
|
96
|
+
// to the base afterwards instead left the compound rate itself charged
|
|
97
|
+
// on the bare net, which is the whole thing the flag exists to change.
|
|
98
|
+
const base = rate.compound ? netMinor + lineTax : netMinor;
|
|
99
|
+
const numerator = base * rate.bps;
|
|
100
|
+
const taxMinor = roundMinor(numerator, BPS_DENOMINATOR, mode);
|
|
101
|
+
exactTaxNumerator += numerator;
|
|
102
|
+
breakdown.push({ code: rate.code, bps: rate.bps, taxMinor });
|
|
103
|
+
lineTax += taxMinor;
|
|
104
|
+
const seen = tally.get(rate.code) ?? { bps: rate.bps, taxMinor: 0, netMinor: 0 };
|
|
105
|
+
tally.set(rate.code, {
|
|
106
|
+
bps: rate.bps,
|
|
107
|
+
taxMinor: seen.taxMinor + taxMinor,
|
|
108
|
+
netMinor: seen.netMinor + netMinor,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
results.push({
|
|
112
|
+
id: line.id,
|
|
113
|
+
netMinor,
|
|
114
|
+
taxMinor: lineTax,
|
|
115
|
+
grossMinor: netMinor + lineTax,
|
|
116
|
+
breakdown,
|
|
117
|
+
note: options.reverseCharge
|
|
118
|
+
? "reverse charge: the customer accounts for the tax"
|
|
119
|
+
: line.exempt
|
|
120
|
+
? "exempt: outside the scope of tax, which is not a zero rate"
|
|
121
|
+
: "",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const netMinor = results.reduce((s, r) => s + r.netMinor, 0);
|
|
125
|
+
const summedTax = results.reduce((s, r) => s + r.taxMinor, 0);
|
|
126
|
+
const taxMinor = scope === "invoice" ? roundMinor(exactTaxNumerator, BPS_DENOMINATOR, mode) : summedTax;
|
|
127
|
+
return {
|
|
128
|
+
lines: results,
|
|
129
|
+
netMinor,
|
|
130
|
+
taxMinor,
|
|
131
|
+
grossMinor: netMinor + taxMinor,
|
|
132
|
+
byCode: [...tally.entries()]
|
|
133
|
+
.map(([code, v]) => ({ code, ...v }))
|
|
134
|
+
.sort((a, b) => (a.code < b.code ? -1 : 1)),
|
|
135
|
+
reverseCharge: options.reverseCharge === true,
|
|
136
|
+
rounding: mode,
|
|
137
|
+
scope,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type VerifyOptions = {
|
|
2
|
+
/** How old a signature may be, in seconds. Default 300, as Stripe's does. */
|
|
3
|
+
readonly toleranceSeconds?: number;
|
|
4
|
+
/** Epoch milliseconds. Supplied rather than read, so a replay is reproducible. */
|
|
5
|
+
readonly nowMs: number;
|
|
6
|
+
/** The signature scheme in the header. Default `v1`. */
|
|
7
|
+
readonly scheme?: string;
|
|
8
|
+
};
|
|
9
|
+
export type VerifyResult = {
|
|
10
|
+
readonly valid: boolean;
|
|
11
|
+
/** Empty when valid; otherwise exactly what failed. */
|
|
12
|
+
readonly reason: string;
|
|
13
|
+
readonly timestamp: number | null;
|
|
14
|
+
readonly ageSeconds: number | null;
|
|
15
|
+
/** How many candidate signatures the header carried. */
|
|
16
|
+
readonly signaturesTried: number;
|
|
17
|
+
};
|
|
18
|
+
/** Parse `t=1614556800,v1=abc,v1=def` into its parts. */
|
|
19
|
+
export declare function parseSignatureHeader(header: string): {
|
|
20
|
+
timestamp: number | null;
|
|
21
|
+
signatures: Map<string, string[]>;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Verify a webhook signature.
|
|
25
|
+
*
|
|
26
|
+
* `body` must be the RAW bytes as received. Re-serializing parsed JSON
|
|
27
|
+
* changes key order and whitespace, and the signature is over the bytes —
|
|
28
|
+
* which is why this takes a string or a Buffer and never an object.
|
|
29
|
+
*/
|
|
30
|
+
export declare function verifyWebhookSignature(body: string | Uint8Array, header: string, secret: string, options: VerifyOptions): VerifyResult;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify a signed webhook before believing a word of it.
|
|
3
|
+
*
|
|
4
|
+
* A payment webhook says money moved. Anyone who can reach the endpoint can
|
|
5
|
+
* post that claim, so the signature is the only thing separating an event
|
|
6
|
+
* from an assertion by a stranger — and a harness that acts on an unverified
|
|
7
|
+
* "payment succeeded" ships goods for free.
|
|
8
|
+
*
|
|
9
|
+
* The scheme implemented is Stripe's, which several providers copy: a header
|
|
10
|
+
* carrying a timestamp and one or more signatures, over `timestamp.body`,
|
|
11
|
+
* HMAC-SHA256, hex. The comparison is constant-time and the timestamp is
|
|
12
|
+
* checked, because a signature that never expires is a replay waiting to
|
|
13
|
+
* happen.
|
|
14
|
+
*/
|
|
15
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
16
|
+
/** Parse `t=1614556800,v1=abc,v1=def` into its parts. */
|
|
17
|
+
export function parseSignatureHeader(header) {
|
|
18
|
+
const signatures = new Map();
|
|
19
|
+
let timestamp = null;
|
|
20
|
+
for (const piece of header.split(",")) {
|
|
21
|
+
const eq = piece.indexOf("=");
|
|
22
|
+
if (eq === -1)
|
|
23
|
+
continue;
|
|
24
|
+
const key = piece.slice(0, eq).trim();
|
|
25
|
+
const value = piece.slice(eq + 1).trim();
|
|
26
|
+
if (key === "t") {
|
|
27
|
+
const parsed = Number.parseInt(value, 10);
|
|
28
|
+
timestamp = Number.isNaN(parsed) ? null : parsed;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const list = signatures.get(key);
|
|
32
|
+
if (list)
|
|
33
|
+
list.push(value);
|
|
34
|
+
else
|
|
35
|
+
signatures.set(key, [value]);
|
|
36
|
+
}
|
|
37
|
+
return { timestamp, signatures };
|
|
38
|
+
}
|
|
39
|
+
/** Compare two hex digests without leaking where they first differ. */
|
|
40
|
+
function constantTimeEqualHex(a, b) {
|
|
41
|
+
if (a.length !== b.length)
|
|
42
|
+
return false;
|
|
43
|
+
let left;
|
|
44
|
+
let right;
|
|
45
|
+
try {
|
|
46
|
+
left = Buffer.from(a, "hex");
|
|
47
|
+
right = Buffer.from(b, "hex");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (left.length !== right.length || left.length === 0)
|
|
53
|
+
return false;
|
|
54
|
+
return timingSafeEqual(left, right);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Verify a webhook signature.
|
|
58
|
+
*
|
|
59
|
+
* `body` must be the RAW bytes as received. Re-serializing parsed JSON
|
|
60
|
+
* changes key order and whitespace, and the signature is over the bytes —
|
|
61
|
+
* which is why this takes a string or a Buffer and never an object.
|
|
62
|
+
*/
|
|
63
|
+
export function verifyWebhookSignature(body, header, secret, options) {
|
|
64
|
+
const { timestamp, signatures } = parseSignatureHeader(header);
|
|
65
|
+
const scheme = options.scheme ?? "v1";
|
|
66
|
+
const candidates = signatures.get(scheme) ?? [];
|
|
67
|
+
if (timestamp === null) {
|
|
68
|
+
return {
|
|
69
|
+
valid: false,
|
|
70
|
+
reason: "the header carries no timestamp",
|
|
71
|
+
timestamp: null,
|
|
72
|
+
ageSeconds: null,
|
|
73
|
+
signaturesTried: 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (candidates.length === 0) {
|
|
77
|
+
return {
|
|
78
|
+
valid: false,
|
|
79
|
+
reason: `the header carries no "${scheme}" signature`,
|
|
80
|
+
timestamp,
|
|
81
|
+
ageSeconds: null,
|
|
82
|
+
signaturesTried: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const ageSeconds = Math.floor(options.nowMs / 1000) - timestamp;
|
|
86
|
+
const tolerance = options.toleranceSeconds ?? 300;
|
|
87
|
+
const payload = Buffer.concat([
|
|
88
|
+
Buffer.from(`${timestamp}.`, "utf8"),
|
|
89
|
+
typeof body === "string" ? Buffer.from(body, "utf8") : Buffer.from(body),
|
|
90
|
+
]);
|
|
91
|
+
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
|
92
|
+
const matched = candidates.some((candidate) => constantTimeEqualHex(candidate, expected));
|
|
93
|
+
// The signature is checked first and the age second, so a forged request
|
|
94
|
+
// cannot learn anything from which complaint it gets back.
|
|
95
|
+
if (!matched) {
|
|
96
|
+
return {
|
|
97
|
+
valid: false,
|
|
98
|
+
reason: "no signature in the header matches the body",
|
|
99
|
+
timestamp,
|
|
100
|
+
ageSeconds,
|
|
101
|
+
signaturesTried: candidates.length,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (Math.abs(ageSeconds) > tolerance) {
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
reason: ageSeconds > 0
|
|
108
|
+
? `the signature is ${ageSeconds}s old, past the ${tolerance}s tolerance — it may be a replay`
|
|
109
|
+
: `the signature is timestamped ${-ageSeconds}s in the future, past the ${tolerance}s tolerance`,
|
|
110
|
+
timestamp,
|
|
111
|
+
ageSeconds,
|
|
112
|
+
signaturesTried: candidates.length,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { valid: true, reason: "", timestamp, ageSeconds, signaturesTried: candidates.length };
|
|
116
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
/** Raised when a caller-supplied path resolves outside the workspace root. */
|
|
3
|
+
export declare class ToolPermissionError extends CrewhausError {
|
|
4
|
+
readonly name = "ToolPermissionError";
|
|
5
|
+
readonly toolName: string;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
constructor(toolName: string, attemptedPath: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A validated path, in both forms the tools need.
|
|
11
|
+
*
|
|
12
|
+
* `real` has every in-workspace symlink already resolved and is what I/O
|
|
13
|
+
* should use — operating on it closes most of the check-to-use window.
|
|
14
|
+
* `abs` is the lexical resolution, which is what a tool must use when the
|
|
15
|
+
* question is about the link ITSELF (`Stat` reporting a symlink target,
|
|
16
|
+
* `RemovePath` deleting a link rather than its destination).
|
|
17
|
+
*/
|
|
18
|
+
export type SafePath = {
|
|
19
|
+
readonly abs: string;
|
|
20
|
+
readonly real: string;
|
|
21
|
+
/** The path as the caller wrote it, for echoing back in results. */
|
|
22
|
+
readonly given: string;
|
|
23
|
+
/** Slash-separated path relative to the workspace root. */
|
|
24
|
+
readonly rel: string;
|
|
25
|
+
};
|
|
26
|
+
/** The workspace root, resolved. Every path is contained within it. */
|
|
27
|
+
export declare function workspaceRoot(): string;
|
|
28
|
+
export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
|
|
29
|
+
/** A path relative to the workspace root, with `/` separators on every OS. */
|
|
30
|
+
export declare function toPosix(p: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* True when `candidate` is `parent` or lives underneath it. Both must already
|
|
33
|
+
* be absolute. Used to keep an archive entry, or a symlink inside an
|
|
34
|
+
* extracted tree, from reaching outside its destination.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isInside(parent: string, candidate: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Reject an archive member name that would escape its destination: an
|
|
39
|
+
* absolute path, a Windows drive prefix, or any `..` segment. Checked on the
|
|
40
|
+
* NAME, before extraction, because after extraction the damage is done.
|
|
41
|
+
*/
|
|
42
|
+
export declare function archiveEntryEscapes(name: string): boolean;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path containment — the gate every caller-supplied path in this package
|
|
3
|
+
* passes through before any syscall touches it.
|
|
4
|
+
*
|
|
5
|
+
* The approach is the one `@crewhaus/tool-fs` uses, and for the same reasons:
|
|
6
|
+
* a lexical check alone is fooled by a symlink that lives inside the
|
|
7
|
+
* workspace and points outside it (CWE-59), so the real path is checked too.
|
|
8
|
+
* The workspace root is `process.cwd()`, matching tool-fs, so a harness that
|
|
9
|
+
* trusts one tool's boundary gets the same boundary here.
|
|
10
|
+
*/
|
|
11
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
14
|
+
/** Raised when a caller-supplied path resolves outside the workspace root. */
|
|
15
|
+
export class ToolPermissionError extends CrewhausError {
|
|
16
|
+
name = "ToolPermissionError";
|
|
17
|
+
toolName;
|
|
18
|
+
path;
|
|
19
|
+
constructor(toolName, attemptedPath) {
|
|
20
|
+
super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
|
|
21
|
+
this.toolName = toolName;
|
|
22
|
+
this.path = attemptedPath;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The workspace root, resolved. Every path is contained within it. */
|
|
26
|
+
export function workspaceRoot() {
|
|
27
|
+
return path.resolve(process.cwd());
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve `rel` inside the workspace, or throw `ToolPermissionError`.
|
|
31
|
+
*
|
|
32
|
+
* The leaf is allowed not to exist — every tool here that creates something
|
|
33
|
+
* needs to validate a destination before it is made — so the deepest
|
|
34
|
+
* EXISTING ancestor is realpath'd and the missing tail re-appended. Any
|
|
35
|
+
* failure other than that walk fails closed.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* True when the NAME exists, whether or not it leads anywhere.
|
|
39
|
+
*
|
|
40
|
+
* `existsSync` FOLLOWS symlinks, so it answers false for a link whose target
|
|
41
|
+
* is missing — and that is exactly the case that matters: a dangling link is
|
|
42
|
+
* still a door, because `open(…, "w")` through `evil -> /outside/x` CREATES
|
|
43
|
+
* `/outside/x`. Probing with `lstat` keeps the link's name in the part of the
|
|
44
|
+
* path that gets resolved, rather than in the "does not exist yet" tail that
|
|
45
|
+
* is re-appended to the root verbatim and so passes the containment check.
|
|
46
|
+
*/
|
|
47
|
+
function nameExists(p) {
|
|
48
|
+
try {
|
|
49
|
+
lstatSync(p);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Where `target` would actually land, with every symlink on the way already
|
|
58
|
+
* followed — including one whose own target does not exist yet.
|
|
59
|
+
*
|
|
60
|
+
* `realpathSync` gives up with ENOENT on a dangling link, which would leave
|
|
61
|
+
* that link unresolved and let it stand in for a plain missing file. So the
|
|
62
|
+
* deepest ancestor that exists as a NAME is resolved, a dangling one is
|
|
63
|
+
* followed a hop by hand, and the components that do not exist are appended.
|
|
64
|
+
* The result is the path an `open` would create, which is the only path worth
|
|
65
|
+
* checking containment against.
|
|
66
|
+
*/
|
|
67
|
+
function resolveLocation(target, depth = 0) {
|
|
68
|
+
if (depth > 40)
|
|
69
|
+
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
|
+
}
|
|
79
|
+
let probeReal;
|
|
80
|
+
try {
|
|
81
|
+
probeReal = realpathSync(probe);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err.code !== "ENOENT")
|
|
85
|
+
throw err;
|
|
86
|
+
// The name is there but `realpath` cannot finish it: a symlink with a
|
|
87
|
+
// missing target. `readlinkSync` throws EINVAL on anything else, which
|
|
88
|
+
// fails closed.
|
|
89
|
+
const link = readlinkSync(probe);
|
|
90
|
+
// A RELATIVE target resolves against the directory that actually CONTAINS
|
|
91
|
+
// the link, which is not the link's lexical parent when that parent is
|
|
92
|
+
// itself reached through a symlink. `<root>/dirlink/x -> ../y` with
|
|
93
|
+
// `dirlink` pointing out of the root really lands at `<elsewhere>/y`, but
|
|
94
|
+
// measured from the lexical parent it reads as `<root>/y` — an in-root
|
|
95
|
+
// path the caller's path does not lead to. So the parent is made real
|
|
96
|
+
// first. An absolute target ignores the base.
|
|
97
|
+
const base = realpathSync(path.dirname(probe));
|
|
98
|
+
probeReal = resolveLocation(path.resolve(base, link), depth + 1);
|
|
99
|
+
}
|
|
100
|
+
return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
|
|
101
|
+
}
|
|
102
|
+
export function resolveSafe(toolName, rel, root = workspaceRoot()) {
|
|
103
|
+
const rootResolved = path.resolve(root);
|
|
104
|
+
const abs = path.resolve(rootResolved, rel);
|
|
105
|
+
// 1) Lexical containment — rejects `..` and absolute escapes. The trailing
|
|
106
|
+
// separator avoids the `/root` vs `/root-sibling` prefix pitfall.
|
|
107
|
+
if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
|
|
108
|
+
throw new ToolPermissionError(toolName, rel);
|
|
109
|
+
}
|
|
110
|
+
let real;
|
|
111
|
+
try {
|
|
112
|
+
const rootReal = realpathSync(rootResolved);
|
|
113
|
+
real = resolveLocation(abs);
|
|
114
|
+
// 2) Symlink-aware containment. The real path must land inside the real
|
|
115
|
+
// root, so an in-workspace symlink pointing at /etc is refused here.
|
|
116
|
+
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
|
|
117
|
+
throw new ToolPermissionError(toolName, rel);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof ToolPermissionError)
|
|
122
|
+
throw err;
|
|
123
|
+
throw new ToolPermissionError(toolName, rel);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
abs,
|
|
127
|
+
real,
|
|
128
|
+
given: rel,
|
|
129
|
+
rel: toPosix(path.relative(rootResolved, abs)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** A path relative to the workspace root, with `/` separators on every OS. */
|
|
133
|
+
export function toPosix(p) {
|
|
134
|
+
return path.sep === "/" ? p : p.split(path.sep).join("/");
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* True when `candidate` is `parent` or lives underneath it. Both must already
|
|
138
|
+
* be absolute. Used to keep an archive entry, or a symlink inside an
|
|
139
|
+
* extracted tree, from reaching outside its destination.
|
|
140
|
+
*/
|
|
141
|
+
export function isInside(parent, candidate) {
|
|
142
|
+
const p = path.resolve(parent);
|
|
143
|
+
const c = path.resolve(candidate);
|
|
144
|
+
return c === p || c.startsWith(`${p}${path.sep}`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Reject an archive member name that would escape its destination: an
|
|
148
|
+
* absolute path, a Windows drive prefix, or any `..` segment. Checked on the
|
|
149
|
+
* NAME, before extraction, because after extraction the damage is done.
|
|
150
|
+
*/
|
|
151
|
+
export function archiveEntryEscapes(name) {
|
|
152
|
+
const normalized = name.replace(/\\/g, "/");
|
|
153
|
+
if (normalized.startsWith("/"))
|
|
154
|
+
return true;
|
|
155
|
+
if (/^[A-Za-z]:/.test(normalized))
|
|
156
|
+
return true;
|
|
157
|
+
return normalized.split("/").some((segment) => segment === "..");
|
|
158
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-money",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic money tools: payment identifier checksums, tax, refund allocation, three-way match, cost basis, spend controls and statement parsing",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"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",
|
|
22
|
+
"zod": "^3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Max Meier",
|
|
30
|
+
"email": "max@crewhaus.ai",
|
|
31
|
+
"url": "https://crewhaus.ai"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
36
|
+
"directory": "packages/tool-money"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-money#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
46
|
+
}
|