@crewhaus/tool-ledger 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 +243 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +888 -0
- package/dist/lib/amount.d.ts +73 -0
- package/dist/lib/amount.js +204 -0
- package/dist/lib/db.d.ts +171 -0
- package/dist/lib/db.js +326 -0
- package/dist/lib/invoice.d.ts +222 -0
- package/dist/lib/invoice.js +500 -0
- package/dist/lib/post.d.ts +129 -0
- package/dist/lib/post.js +440 -0
- package/dist/lib/query.d.ts +62 -0
- package/dist/lib/query.js +480 -0
- package/dist/lib/reconcile.d.ts +117 -0
- package/dist/lib/reconcile.js +335 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +48 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { LedgerError, assertIsoDate, byString, dayDiff, formatMajor } from "./amount";
|
|
2
|
+
export const MATCH_KINDS = ["reference", "amount-date", "grouped", "grouped-with-fee"];
|
|
3
|
+
export const RECONCILE_LIMITS = {
|
|
4
|
+
rowsPerSide: 20_000,
|
|
5
|
+
maxSubsetSize: 8,
|
|
6
|
+
/** Subsets examined for ONE right-hand row before the search gives up. */
|
|
7
|
+
maxCombinations: 200_000,
|
|
8
|
+
};
|
|
9
|
+
/** Rows in a fixed order, and a refusal when two of them share an id. */
|
|
10
|
+
function prepare(rows, side) {
|
|
11
|
+
if (rows.length > RECONCILE_LIMITS.rowsPerSide) {
|
|
12
|
+
throw new LedgerError(`the ${side} side has ${rows.length} rows, over the ${RECONCILE_LIMITS.rowsPerSide} limit`);
|
|
13
|
+
}
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for (const row of rows) {
|
|
16
|
+
if (seen.has(row.id)) {
|
|
17
|
+
throw new LedgerError(`two rows on the ${side} side share the id "${row.id}" — a match against an ambiguous id cannot be acted on, so fix the ids rather than have this pick one`);
|
|
18
|
+
}
|
|
19
|
+
seen.add(row.id);
|
|
20
|
+
assertIsoDate(row.date, `${side} row "${row.id}" date`);
|
|
21
|
+
if (!Number.isSafeInteger(row.amountMinor)) {
|
|
22
|
+
throw new LedgerError(`${side} row "${row.id}" has amountMinor ${row.amountMinor}, which is not a whole number of minor units`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Date then id: the order every later pass iterates in, so "the first
|
|
26
|
+
// candidate" means the same thing on every run and on every machine.
|
|
27
|
+
return [...rows].sort((a, b) => byString(a.date, b.date) || byString(a.id, b.id));
|
|
28
|
+
}
|
|
29
|
+
const normalizeReference = (value) => value
|
|
30
|
+
.trim()
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9]/g, "");
|
|
33
|
+
const absolute = (v) => (v < 0n ? -v : v);
|
|
34
|
+
export function reconcile(leftRows, rightRows, options) {
|
|
35
|
+
const left = prepare(leftRows, "left");
|
|
36
|
+
const right = prepare(rightRows, "right");
|
|
37
|
+
const money = (v) => formatMajor(v, options.exponent);
|
|
38
|
+
const amountOf = (t) => BigInt(t.amountMinor);
|
|
39
|
+
const rightDates = new Map(right.map((r) => [r.id, r.date]));
|
|
40
|
+
const claimedLeft = new Set();
|
|
41
|
+
const claimedRight = new Set();
|
|
42
|
+
const matched = [];
|
|
43
|
+
const nearMisses = [];
|
|
44
|
+
const groupingTruncated = [];
|
|
45
|
+
const inWindow = (a, b) => Math.abs(dayDiff(a.date, b.date)) <= options.windowDays;
|
|
46
|
+
// --- pass 1: the reference both sides carry --------------------------------
|
|
47
|
+
// An identifier that matches is the strongest evidence there is, which is
|
|
48
|
+
// exactly why a matching reference with a DIFFERENT amount is reported as a
|
|
49
|
+
// near miss and not matched: that pair is the most interesting row in the
|
|
50
|
+
// file, and pairing it would hide it.
|
|
51
|
+
for (const l of left) {
|
|
52
|
+
if (claimedLeft.has(l.id))
|
|
53
|
+
continue;
|
|
54
|
+
const key = normalizeReference(l.reference);
|
|
55
|
+
if (key === "")
|
|
56
|
+
continue;
|
|
57
|
+
const candidates = right.filter((r) => !claimedRight.has(r.id) && normalizeReference(r.reference) === key);
|
|
58
|
+
const exact = candidates.find((r) => amountOf(r) === amountOf(l));
|
|
59
|
+
if (exact !== undefined) {
|
|
60
|
+
claimedLeft.add(l.id);
|
|
61
|
+
claimedRight.add(exact.id);
|
|
62
|
+
matched.push({
|
|
63
|
+
kind: "reference",
|
|
64
|
+
leftIds: [l.id],
|
|
65
|
+
rightId: exact.id,
|
|
66
|
+
amountMinor: amountOf(l).toString(),
|
|
67
|
+
amount: money(amountOf(l)),
|
|
68
|
+
dateDeltaDays: dayDiff(l.date, exact.date),
|
|
69
|
+
feeMinor: "0",
|
|
70
|
+
why: `reference "${l.reference}" and an identical amount`,
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const candidate = candidates[0];
|
|
75
|
+
if (candidate !== undefined) {
|
|
76
|
+
const delta = amountOf(l) - amountOf(candidate);
|
|
77
|
+
nearMisses.push({
|
|
78
|
+
kind: "reference",
|
|
79
|
+
leftId: l.id,
|
|
80
|
+
rightId: candidate.id,
|
|
81
|
+
amountDeltaMinor: delta.toString(),
|
|
82
|
+
amountDelta: money(delta),
|
|
83
|
+
dateDeltaDays: dayDiff(l.date, candidate.date),
|
|
84
|
+
why: `reference "${l.reference}" is the same on both sides but the amounts differ by ${money(delta)} — neither row is matched, because the disagreement is the finding`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (options.requireReferenceMatch) {
|
|
89
|
+
return assemble();
|
|
90
|
+
}
|
|
91
|
+
// --- pass 2: identical amount inside the date window -----------------------
|
|
92
|
+
for (const l of left) {
|
|
93
|
+
if (claimedLeft.has(l.id))
|
|
94
|
+
continue;
|
|
95
|
+
const candidates = right
|
|
96
|
+
.filter((r) => !claimedRight.has(r.id) && amountOf(r) === amountOf(l) && inWindow(l, r))
|
|
97
|
+
.sort((a, b) => Math.abs(dayDiff(l.date, a.date)) - Math.abs(dayDiff(l.date, b.date)) ||
|
|
98
|
+
byString(a.date, b.date) ||
|
|
99
|
+
byString(a.id, b.id));
|
|
100
|
+
const pick = candidates[0];
|
|
101
|
+
if (pick === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
claimedLeft.add(l.id);
|
|
104
|
+
claimedRight.add(pick.id);
|
|
105
|
+
matched.push({
|
|
106
|
+
kind: "amount-date",
|
|
107
|
+
leftIds: [l.id],
|
|
108
|
+
rightId: pick.id,
|
|
109
|
+
amountMinor: amountOf(l).toString(),
|
|
110
|
+
amount: money(amountOf(l)),
|
|
111
|
+
dateDeltaDays: dayDiff(l.date, pick.date),
|
|
112
|
+
feeMinor: "0",
|
|
113
|
+
why: `identical amount within ${options.windowDays} day(s)`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// --- pass 3: near misses ---------------------------------------------------
|
|
117
|
+
if (options.toleranceMinor > 0n) {
|
|
118
|
+
for (const l of left) {
|
|
119
|
+
if (claimedLeft.has(l.id))
|
|
120
|
+
continue;
|
|
121
|
+
const candidates = right
|
|
122
|
+
.filter((r) => {
|
|
123
|
+
if (claimedRight.has(r.id) || !inWindow(l, r))
|
|
124
|
+
return false;
|
|
125
|
+
const delta = absolute(amountOf(l) - amountOf(r));
|
|
126
|
+
return delta > 0n && delta <= options.toleranceMinor;
|
|
127
|
+
})
|
|
128
|
+
.sort((a, b) => {
|
|
129
|
+
const da = absolute(amountOf(l) - amountOf(a));
|
|
130
|
+
const dbb = absolute(amountOf(l) - amountOf(b));
|
|
131
|
+
return da < dbb ? -1 : da > dbb ? 1 : byString(a.date, b.date) || byString(a.id, b.id);
|
|
132
|
+
});
|
|
133
|
+
const pick = candidates[0];
|
|
134
|
+
if (pick === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
const delta = amountOf(l) - amountOf(pick);
|
|
137
|
+
nearMisses.push({
|
|
138
|
+
kind: "tolerance",
|
|
139
|
+
leftId: l.id,
|
|
140
|
+
rightId: pick.id,
|
|
141
|
+
amountDeltaMinor: delta.toString(),
|
|
142
|
+
amountDelta: money(delta),
|
|
143
|
+
dateDeltaDays: dayDiff(l.date, pick.date),
|
|
144
|
+
why: `the amounts differ by ${money(delta)}, inside the tolerance — reported for a person to decide, NOT matched`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// --- pass 4: several left rows bundled into one right row ------------------
|
|
149
|
+
if (options.allowManyToOne) {
|
|
150
|
+
for (const r of right) {
|
|
151
|
+
if (claimedRight.has(r.id))
|
|
152
|
+
continue;
|
|
153
|
+
const pool = left.filter((l) => !claimedLeft.has(l.id) && inWindow(l, r));
|
|
154
|
+
const found = findSubset(pool, amountOf(r), options.maxSubsetSize, options.maxCombinations, options.feeToleranceMinor, amountOf);
|
|
155
|
+
if (found.truncated)
|
|
156
|
+
groupingTruncated.push(r.id);
|
|
157
|
+
if (found.subset === null)
|
|
158
|
+
continue;
|
|
159
|
+
for (const l of found.subset)
|
|
160
|
+
claimedLeft.add(l.id);
|
|
161
|
+
claimedRight.add(r.id);
|
|
162
|
+
const sum = found.subset.reduce((s, l) => s + amountOf(l), 0n);
|
|
163
|
+
const fee = sum - amountOf(r);
|
|
164
|
+
matched.push({
|
|
165
|
+
kind: fee === 0n ? "grouped" : "grouped-with-fee",
|
|
166
|
+
leftIds: found.subset.map((l) => l.id),
|
|
167
|
+
rightId: r.id,
|
|
168
|
+
amountMinor: amountOf(r).toString(),
|
|
169
|
+
amount: money(amountOf(r)),
|
|
170
|
+
dateDeltaDays: Math.max(...found.subset.map((l) => Math.abs(dayDiff(l.date, r.date)))),
|
|
171
|
+
feeMinor: fee.toString(),
|
|
172
|
+
why: fee === 0n
|
|
173
|
+
? `${found.subset.length} rows sum exactly to this one`
|
|
174
|
+
: `${found.subset.length} rows sum to ${money(sum)} against ${money(amountOf(r))}; the ${money(fee)} difference is within the fee tolerance and is proposed as a fee entry, never absorbed silently`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return assemble();
|
|
179
|
+
function assemble() {
|
|
180
|
+
const unmatchedLeft = left.filter((l) => !claimedLeft.has(l.id)).map((l) => describe(l, money));
|
|
181
|
+
const unmatchedRight = right
|
|
182
|
+
.filter((r) => !claimedRight.has(r.id))
|
|
183
|
+
.map((r) => describe(r, money));
|
|
184
|
+
// A row flagged on AMOUNT alone and then explained by a later pass is no
|
|
185
|
+
// longer a near miss; reporting it in both lists would have a reviewer
|
|
186
|
+
// chasing a row that is already accounted for.
|
|
187
|
+
//
|
|
188
|
+
// A REFERENCE disagreement is never dropped, whatever claims the rows
|
|
189
|
+
// afterwards. Pass 2 will happily pair the left row with some other row of
|
|
190
|
+
// the same amount, and dropping the near miss then would delete the only
|
|
191
|
+
// record that two documents carrying one identifier disagree about the
|
|
192
|
+
// money — which is the finding this module says it exists to preserve, and
|
|
193
|
+
// "the amounts happened to match somewhere else" is not an explanation of
|
|
194
|
+
// it.
|
|
195
|
+
const openNearMisses = nearMisses.filter((n) => n.kind === "reference" || (!claimedLeft.has(n.leftId) && !claimedRight.has(n.rightId)));
|
|
196
|
+
const leftTotal = left.reduce((s, l) => s + amountOf(l), 0n);
|
|
197
|
+
const rightTotal = right.reduce((s, r) => s + amountOf(r), 0n);
|
|
198
|
+
return {
|
|
199
|
+
matched,
|
|
200
|
+
nearMisses: openNearMisses,
|
|
201
|
+
unmatchedLeft,
|
|
202
|
+
unmatchedRight,
|
|
203
|
+
proposedEntries: propose(matched, unmatchedRight, rightDates, options, money),
|
|
204
|
+
summary: {
|
|
205
|
+
leftCount: left.length,
|
|
206
|
+
rightCount: right.length,
|
|
207
|
+
leftTotalMinor: leftTotal.toString(),
|
|
208
|
+
rightTotalMinor: rightTotal.toString(),
|
|
209
|
+
leftTotal: money(leftTotal),
|
|
210
|
+
rightTotal: money(rightTotal),
|
|
211
|
+
differenceMinor: (leftTotal - rightTotal).toString(),
|
|
212
|
+
difference: money(leftTotal - rightTotal),
|
|
213
|
+
matchedCount: matched.length,
|
|
214
|
+
nearMissCount: openNearMisses.length,
|
|
215
|
+
currency: options.currency,
|
|
216
|
+
},
|
|
217
|
+
groupingTruncated,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function describe(t, money) {
|
|
222
|
+
return {
|
|
223
|
+
id: t.id,
|
|
224
|
+
date: t.date,
|
|
225
|
+
description: t.description,
|
|
226
|
+
amountMinor: String(t.amountMinor),
|
|
227
|
+
amount: money(BigInt(t.amountMinor)),
|
|
228
|
+
reference: t.reference,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The first subset, in canonical order, that sums to `target` — or to
|
|
233
|
+
* `target` plus a residue no larger than `feeTolerance` when that is on.
|
|
234
|
+
*
|
|
235
|
+
* "First in canonical order" is the whole design. Subsets are walked by
|
|
236
|
+
* ascending SIZE and then lexicographically over a pool already sorted by
|
|
237
|
+
* (date, id), and the search returns immediately on a hit. So when three
|
|
238
|
+
* different bundles of charges add up to the same payout — which happens
|
|
239
|
+
* constantly with round numbers — the same bundle is chosen every time, and
|
|
240
|
+
* the fee entry proposed next to it does not move between runs. Collecting
|
|
241
|
+
* every solution and then picking would give the same answer and cost the
|
|
242
|
+
* exponential blow-up this cap exists to avoid.
|
|
243
|
+
*/
|
|
244
|
+
function findSubset(pool, target, maxSize, maxCombinations, feeTolerance, amountOf) {
|
|
245
|
+
let examined = 0;
|
|
246
|
+
const acceptable = (sum) => {
|
|
247
|
+
if (sum === target)
|
|
248
|
+
return true;
|
|
249
|
+
if (feeTolerance === 0n)
|
|
250
|
+
return false;
|
|
251
|
+
const residue = sum - target;
|
|
252
|
+
// A fee is withheld FROM the bundle, so the payout is smaller in
|
|
253
|
+
// magnitude. A residue the other way means the payout exceeds the charges
|
|
254
|
+
// it supposedly bundles, which is not a fee and is not quietly absorbed.
|
|
255
|
+
if (residue === 0n)
|
|
256
|
+
return false;
|
|
257
|
+
if (absolute(sum) <= absolute(target))
|
|
258
|
+
return false;
|
|
259
|
+
return absolute(residue) <= feeTolerance;
|
|
260
|
+
};
|
|
261
|
+
for (let size = 2; size <= Math.min(maxSize, pool.length); size++) {
|
|
262
|
+
const indices = Array.from({ length: size }, (_, i) => i);
|
|
263
|
+
for (;;) {
|
|
264
|
+
examined += 1;
|
|
265
|
+
if (examined > maxCombinations)
|
|
266
|
+
return { subset: null, truncated: true };
|
|
267
|
+
const subset = indices.map((i) => pool[i]);
|
|
268
|
+
if (acceptable(subset.reduce((s, t) => s + amountOf(t), 0n))) {
|
|
269
|
+
return { subset, truncated: false };
|
|
270
|
+
}
|
|
271
|
+
// Standard lexicographic successor over combinations.
|
|
272
|
+
let cursor = size - 1;
|
|
273
|
+
while (cursor >= 0 && indices[cursor] === pool.length - size + cursor)
|
|
274
|
+
cursor--;
|
|
275
|
+
if (cursor < 0)
|
|
276
|
+
break;
|
|
277
|
+
indices[cursor] = indices[cursor] + 1;
|
|
278
|
+
for (let j = cursor + 1; j < size; j++)
|
|
279
|
+
indices[j] = indices[j - 1] + 1;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { subset: null, truncated: false };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Entries a caller could post, returned and never posted.
|
|
286
|
+
*
|
|
287
|
+
* Nothing in this package writes to the ledger from a reconciliation, and
|
|
288
|
+
* nothing anywhere in it transmits a payment instruction. These are drafts:
|
|
289
|
+
* the fee a payout withheld, and a statement line the ledger has never seen,
|
|
290
|
+
* parked in suspense for a person to code.
|
|
291
|
+
*/
|
|
292
|
+
function propose(matched, unmatchedRight, rightDates, options, money) {
|
|
293
|
+
const accounts = options.propose;
|
|
294
|
+
if (accounts === undefined)
|
|
295
|
+
return [];
|
|
296
|
+
const out = [];
|
|
297
|
+
for (const match of matched) {
|
|
298
|
+
const fee = BigInt(match.feeMinor);
|
|
299
|
+
if (fee === 0n)
|
|
300
|
+
continue;
|
|
301
|
+
const magnitude = absolute(fee);
|
|
302
|
+
out.push({
|
|
303
|
+
// The payout's own date, not an empty string: a draft entry has to be
|
|
304
|
+
// something LedgerPost would actually accept.
|
|
305
|
+
date: rightDates.get(match.rightId) ?? "",
|
|
306
|
+
memo: `processor fee withheld from ${match.rightId}`,
|
|
307
|
+
reference: match.rightId,
|
|
308
|
+
lines: [
|
|
309
|
+
{ account: accounts.feeAccount, debitMinor: magnitude.toString() },
|
|
310
|
+
{ account: accounts.bankAccount, creditMinor: magnitude.toString() },
|
|
311
|
+
],
|
|
312
|
+
why: `the bundle exceeds the payout by ${money(fee)}`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
for (const row of unmatchedRight) {
|
|
316
|
+
const amount = BigInt(row.amountMinor);
|
|
317
|
+
const magnitude = absolute(amount);
|
|
318
|
+
out.push({
|
|
319
|
+
date: row.date,
|
|
320
|
+
memo: row.description === "" ? `unmatched statement line ${row.id}` : row.description,
|
|
321
|
+
reference: row.reference,
|
|
322
|
+
lines: amount >= 0n
|
|
323
|
+
? [
|
|
324
|
+
{ account: accounts.bankAccount, debitMinor: magnitude.toString() },
|
|
325
|
+
{ account: accounts.suspenseAccount, creditMinor: magnitude.toString() },
|
|
326
|
+
]
|
|
327
|
+
: [
|
|
328
|
+
{ account: accounts.suspenseAccount, debitMinor: magnitude.toString() },
|
|
329
|
+
{ account: accounts.bankAccount, creditMinor: magnitude.toString() },
|
|
330
|
+
],
|
|
331
|
+
why: "the ledger has no line for this statement row; parked in suspense for a person to code",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
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,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-ledger",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic double-entry ledger: hash-chained postings, queries, reconciliation and invoice rendering",
|
|
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-datetime": "0.7.0",
|
|
22
|
+
"@crewhaus/tool-math": "0.7.0",
|
|
23
|
+
"@crewhaus/tool-money": "0.7.0",
|
|
24
|
+
"zod": "^3.23.8"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
28
|
+
},
|
|
29
|
+
"license": "Apache-2.0",
|
|
30
|
+
"author": {
|
|
31
|
+
"name": "Max Meier",
|
|
32
|
+
"email": "max@crewhaus.ai",
|
|
33
|
+
"url": "https://crewhaus.ai"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
38
|
+
"directory": "packages/tool-ledger"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-ledger#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
48
|
+
}
|