@openparachute/vault 0.7.1 → 0.7.2-rc.3
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/core/src/core.test.ts +70 -4
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/hooks.ts +9 -0
- package/core/src/mcp.ts +12 -2
- package/core/src/notes.ts +175 -50
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +39 -13
- package/core/src/store.ts +12 -2
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +27 -2
- package/src/vault.test.ts +88 -0
package/core/src/store.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
clearQueuedLink,
|
|
18
18
|
} from "./wikilinks.js";
|
|
19
19
|
import { pathTitle } from "./paths.js";
|
|
20
|
+
import { timestampToMs } from "./cursor.js";
|
|
20
21
|
import { transaction } from "./txn.js";
|
|
21
22
|
import { HookRegistry } from "./hooks.js";
|
|
22
23
|
import {
|
|
@@ -360,9 +361,18 @@ export class BunSqliteStore implements Store {
|
|
|
360
361
|
// the importer write a specific historical timestamp. Skips hooks
|
|
361
362
|
// by design: this isn't a user-edit, it's a state restoration.
|
|
362
363
|
// See vault#308 PR 2.
|
|
364
|
+
//
|
|
365
|
+
// This is THE path by which non-canonical timestamps (space-form,
|
|
366
|
+
// `+02:00` offset, no-`Z`) land in a vault — frontmatter is preserved
|
|
367
|
+
// VERBATIM (byte-identical re-export round-trip), so `updated_at` is NOT
|
|
368
|
+
// canonicalized here. The keyset ordering key `updated_at_ms` (vault#586)
|
|
369
|
+
// is derived UTC-correctly from that verbatim value: `timestampToMs` does
|
|
370
|
+
// NOT read space-form as local time. A genuinely unparseable `updated_at`
|
|
371
|
+
// falls back to `created_at`'s ms, then to 0 — never NULL, never a throw.
|
|
372
|
+
const updatedAtMs = timestampToMs(updatedAt) ?? timestampToMs(createdAt) ?? 0;
|
|
363
373
|
this.db
|
|
364
|
-
.prepare("UPDATE notes SET created_at = ?, updated_at = ? WHERE id = ?")
|
|
365
|
-
.run(createdAt, updatedAt, id);
|
|
374
|
+
.prepare("UPDATE notes SET created_at = ?, updated_at = ?, updated_at_ms = ? WHERE id = ?")
|
|
375
|
+
.run(createdAt, updatedAt, updatedAtMs, id);
|
|
366
376
|
}
|
|
367
377
|
|
|
368
378
|
async deleteNote(id: string): Promise<void> {
|
package/core/src/txn.test.ts
CHANGED
|
@@ -89,9 +89,12 @@ describe("transaction (sync)", () => {
|
|
|
89
89
|
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
it("preserves the callback error
|
|
92
|
+
it("preserves the callback error when ROLLBACK fails for the expected 'already-resolved' reason", () => {
|
|
93
93
|
const callbackErr = new Error("callback boom");
|
|
94
|
-
|
|
94
|
+
// The one swallowed case: the txn was already resolved, so ROLLBACK is a
|
|
95
|
+
// no-op that reports "no transaction is active". The ORIGINAL callback
|
|
96
|
+
// error is the root cause and must still propagate.
|
|
97
|
+
const rollbackErr = new Error("cannot rollback - no transaction is active");
|
|
95
98
|
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
96
99
|
let thrown: unknown;
|
|
97
100
|
try {
|
|
@@ -101,6 +104,22 @@ describe("transaction (sync)", () => {
|
|
|
101
104
|
}
|
|
102
105
|
expect(thrown).toBe(callbackErr);
|
|
103
106
|
});
|
|
107
|
+
|
|
108
|
+
it("re-throws an UNEXPECTED ROLLBACK failure instead of silently masking it (vault#589 NIT 2)", () => {
|
|
109
|
+
const callbackErr = new Error("callback boom");
|
|
110
|
+
// A rollback failure that is NOT the benign "already-resolved" case (a
|
|
111
|
+
// corrupt connection / lost write lock) means the transaction may NOT have
|
|
112
|
+
// rolled back — surfacing it beats a false "clean rollback".
|
|
113
|
+
const rollbackErr = new Error("database disk image is malformed");
|
|
114
|
+
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
115
|
+
let thrown: unknown;
|
|
116
|
+
try {
|
|
117
|
+
transaction(db, () => { throw callbackErr; });
|
|
118
|
+
} catch (e) {
|
|
119
|
+
thrown = e;
|
|
120
|
+
}
|
|
121
|
+
expect(thrown).toBe(rollbackErr);
|
|
122
|
+
});
|
|
104
123
|
});
|
|
105
124
|
|
|
106
125
|
describe("transaction — native transactionSync preference (DO backend)", () => {
|
|
@@ -214,9 +233,9 @@ describe("transactionAsync", () => {
|
|
|
214
233
|
expect(calls).toEqual(["BEGIN IMMEDIATE", "COMMIT", "ROLLBACK"]);
|
|
215
234
|
});
|
|
216
235
|
|
|
217
|
-
it("preserves the callback error
|
|
236
|
+
it("preserves the callback error when ROLLBACK fails for the expected 'already-resolved' reason", async () => {
|
|
218
237
|
const callbackErr = new Error("callback boom");
|
|
219
|
-
const rollbackErr = new Error("rollback
|
|
238
|
+
const rollbackErr = new Error("cannot rollback - no transaction is active");
|
|
220
239
|
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
221
240
|
let thrown: unknown;
|
|
222
241
|
try {
|
|
@@ -226,4 +245,81 @@ describe("transactionAsync", () => {
|
|
|
226
245
|
}
|
|
227
246
|
expect(thrown).toBe(callbackErr);
|
|
228
247
|
});
|
|
248
|
+
|
|
249
|
+
it("re-throws an UNEXPECTED ROLLBACK failure instead of silently masking it (vault#589 NIT 2)", async () => {
|
|
250
|
+
const callbackErr = new Error("callback boom");
|
|
251
|
+
const rollbackErr = new Error("database disk image is malformed");
|
|
252
|
+
const { db } = fakeDb({ rollbackThrows: rollbackErr });
|
|
253
|
+
let thrown: unknown;
|
|
254
|
+
try {
|
|
255
|
+
await transactionAsync(db, async () => { throw callbackErr; });
|
|
256
|
+
} catch (e) {
|
|
257
|
+
thrown = e;
|
|
258
|
+
}
|
|
259
|
+
expect(thrown).toBe(rollbackErr);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("transaction — re-entrancy via SAVEPOINTs (vault#589)", () => {
|
|
264
|
+
it("a nested sync transaction uses SAVEPOINT/RELEASE, not a second BEGIN", () => {
|
|
265
|
+
const { db, calls } = fakeDb();
|
|
266
|
+
const out = transaction(db, () => {
|
|
267
|
+
const inner = transaction(db, () => "inner");
|
|
268
|
+
expect(inner).toBe("inner");
|
|
269
|
+
return "outer";
|
|
270
|
+
});
|
|
271
|
+
expect(out).toBe("outer");
|
|
272
|
+
expect(calls).toEqual([
|
|
273
|
+
"BEGIN IMMEDIATE",
|
|
274
|
+
"SAVEPOINT parachute_sp_1",
|
|
275
|
+
"RELEASE parachute_sp_1",
|
|
276
|
+
"COMMIT",
|
|
277
|
+
]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("an inner throw rolls back to the savepoint; the outer can still commit", () => {
|
|
281
|
+
const db = freshDb();
|
|
282
|
+
transaction(db, () => {
|
|
283
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'keep')");
|
|
284
|
+
try {
|
|
285
|
+
transaction(db, () => {
|
|
286
|
+
db.exec("INSERT INTO t (id, v) VALUES (2, 'drop')");
|
|
287
|
+
throw new Error("inner boom");
|
|
288
|
+
});
|
|
289
|
+
} catch {
|
|
290
|
+
// Outer decides to continue past the failed nested block.
|
|
291
|
+
}
|
|
292
|
+
db.exec("INSERT INTO t (id, v) VALUES (3, 'keep2')");
|
|
293
|
+
});
|
|
294
|
+
// Only the savepoint'd insert (2) was undone; 1 and 3 committed.
|
|
295
|
+
const ids = (db.prepare("SELECT id FROM t ORDER BY id").all() as { id: number }[]).map((r) => r.id);
|
|
296
|
+
expect(ids).toEqual([1, 3]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("an UNCAUGHT inner throw rolls back the WHOLE outer transaction", () => {
|
|
300
|
+
const db = freshDb();
|
|
301
|
+
expect(() =>
|
|
302
|
+
transaction(db, () => {
|
|
303
|
+
db.exec("INSERT INTO t (id, v) VALUES (1, 'a')");
|
|
304
|
+
transaction(db, () => {
|
|
305
|
+
db.exec("INSERT INTO t (id, v) VALUES (2, 'b')");
|
|
306
|
+
throw new Error("boom");
|
|
307
|
+
});
|
|
308
|
+
}),
|
|
309
|
+
).toThrow("boom");
|
|
310
|
+
expect((db.prepare("SELECT COUNT(*) AS c FROM t").get() as { c: number }).c).toBe(0);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("transactionAsync composes a nested sync transaction via SAVEPOINT (the atomic blow-away shape)", async () => {
|
|
314
|
+
const { db, calls } = fakeDb();
|
|
315
|
+
await transactionAsync(db, async () => {
|
|
316
|
+
transaction(db, () => "nested-sync-write");
|
|
317
|
+
});
|
|
318
|
+
expect(calls).toEqual([
|
|
319
|
+
"BEGIN IMMEDIATE",
|
|
320
|
+
"SAVEPOINT parachute_sp_1",
|
|
321
|
+
"RELEASE parachute_sp_1",
|
|
322
|
+
"COMMIT",
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
229
325
|
});
|
package/core/src/txn.ts
CHANGED
|
@@ -14,12 +14,40 @@
|
|
|
14
14
|
* one of them only ever wrote, so acquiring the write lock eagerly is a
|
|
15
15
|
* strict improvement (no lazy busy-upgrade) and never a semantic change.
|
|
16
16
|
*
|
|
17
|
-
* **Nesting
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* **Nesting via SAVEPOINTs** (vault#589): the outermost `transaction` /
|
|
18
|
+
* `transactionAsync` on a connection opens a real `BEGIN IMMEDIATE`; a nested
|
|
19
|
+
* call (one whose connection is already inside a transaction) opens a
|
|
20
|
+
* `SAVEPOINT` instead, so composing an atomic block out of primitives that
|
|
21
|
+
* each open their own transaction never hits SQLite's "cannot start a
|
|
22
|
+
* transaction within a transaction". This is the re-entrancy the original
|
|
23
|
+
* seam anticipated ("the fix is SAVEPOINT-based re-entrancy in the bun
|
|
24
|
+
* implementation here — the call sites stay untouched"). The nesting depth is
|
|
25
|
+
* tracked per connection in {@link txnDepth}; a non-nested caller (depth 0 →
|
|
26
|
+
* 1) is byte-for-byte the old `BEGIN … COMMIT` / `ROLLBACK` path, so existing
|
|
27
|
+
* single-level callers are unaffected. The motivating nested caller is the
|
|
28
|
+
* atomic blow-away import (`importVault` wrapping schema/note/link writes,
|
|
29
|
+
* several of which open their own `transaction`).
|
|
30
|
+
*
|
|
31
|
+
* The `transactionSync` (Durable-Object) delegate path is unchanged — a DO
|
|
32
|
+
* backend supplies its own natively re-entrant primitive, so the SAVEPOINT
|
|
33
|
+
* bookkeeping here only governs the bun `BEGIN` path.
|
|
34
|
+
*
|
|
35
|
+
* **Shared-connection invariant (load-bearing for {@link transactionAsync}).**
|
|
36
|
+
* The bun backend is a single synchronous connection. A `transactionAsync`
|
|
37
|
+
* body — AND anything it calls — must not `await` genuine, slow I/O while the
|
|
38
|
+
* transaction is open: every `await` yields the event loop, and any work that
|
|
39
|
+
* runs during that yield and touches the store (a concurrent request, a
|
|
40
|
+
* mutation hook that writes before its first real await) will `SAVEPOINT`-JOIN
|
|
41
|
+
* this open transaction on the shared connection — committing/rolling back
|
|
42
|
+
* with it, its writes cross-wiped by a `ROLLBACK TO`. The blow-away import
|
|
43
|
+
* (the sole `transactionAsync` wrapping non-batch work) is safe by
|
|
44
|
+
* construction: its replay awaits only already-resolved promises over the
|
|
45
|
+
* synchronous bun store, so it never actually yields to unrelated work
|
|
46
|
+
* mid-transaction, and the mutation hooks it fires do no synchronous store
|
|
47
|
+
* writes (they defer via `queueMicrotask` and arm debounces / kick idempotent
|
|
48
|
+
* workers — see hooks.ts). Any NEW `transactionAsync` caller must preserve
|
|
49
|
+
* this: keep the body's awaits confined to the same synchronous connection,
|
|
50
|
+
* never awaiting network/disk/subprocess I/O with the transaction open.
|
|
23
51
|
*/
|
|
24
52
|
|
|
25
53
|
/** The minimal DB surface the transaction seam needs — kept structural (no
|
|
@@ -38,6 +66,79 @@ export interface TxnCapableDb {
|
|
|
38
66
|
transactionSync?<T>(fn: () => T): T;
|
|
39
67
|
}
|
|
40
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Per-connection nesting depth for the bun `BEGIN` path. Keyed on the db
|
|
71
|
+
* handle so two connections track independently; a `WeakMap` so a dropped
|
|
72
|
+
* connection's entry is collected. Depth 0 = no open transaction; the first
|
|
73
|
+
* `transaction`/`transactionAsync` opens `BEGIN IMMEDIATE` and sets depth 1;
|
|
74
|
+
* each further nested call opens a `SAVEPOINT` and bumps the depth. See the
|
|
75
|
+
* file header (vault#589).
|
|
76
|
+
*/
|
|
77
|
+
const txnDepth = new WeakMap<TxnCapableDb, number>();
|
|
78
|
+
|
|
79
|
+
/** One entry on the nesting stack: whether this frame owns the outer
|
|
80
|
+
* `BEGIN … COMMIT`/`ROLLBACK` (outermost) or a `SAVEPOINT` (nested), plus the
|
|
81
|
+
* depth to restore on exit. */
|
|
82
|
+
interface TxnFrame {
|
|
83
|
+
outermost: boolean;
|
|
84
|
+
savepoint: string | null;
|
|
85
|
+
priorDepth: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open a transaction frame: `BEGIN IMMEDIATE` at depth 0, else a uniquely
|
|
89
|
+
* named `SAVEPOINT`. Bumps {@link txnDepth} only after the SQL succeeds. */
|
|
90
|
+
function enterTxn(db: TxnCapableDb): TxnFrame {
|
|
91
|
+
const priorDepth = txnDepth.get(db) ?? 0;
|
|
92
|
+
if (priorDepth === 0) {
|
|
93
|
+
db.exec("BEGIN IMMEDIATE");
|
|
94
|
+
txnDepth.set(db, 1);
|
|
95
|
+
return { outermost: true, savepoint: null, priorDepth };
|
|
96
|
+
}
|
|
97
|
+
const savepoint = `parachute_sp_${priorDepth}`;
|
|
98
|
+
db.exec(`SAVEPOINT ${savepoint}`);
|
|
99
|
+
txnDepth.set(db, priorDepth + 1);
|
|
100
|
+
return { outermost: false, savepoint, priorDepth };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Commit a frame: `COMMIT` for the outermost, `RELEASE` for a savepoint
|
|
104
|
+
* (which merges its writes into the enclosing transaction). */
|
|
105
|
+
function commitTxn(db: TxnCapableDb, frame: TxnFrame): void {
|
|
106
|
+
if (frame.outermost) db.exec("COMMIT");
|
|
107
|
+
else db.exec(`RELEASE ${frame.savepoint}`);
|
|
108
|
+
txnDepth.set(db, frame.priorDepth);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Roll a frame back: `ROLLBACK` for the outermost (whole transaction),
|
|
112
|
+
* `ROLLBACK TO` + `RELEASE` for a savepoint (unwind just this frame; the
|
|
113
|
+
* error still propagates so an enclosing frame decides its own fate).
|
|
114
|
+
* Best-effort — if the COMMIT itself threw, the transaction is already
|
|
115
|
+
* resolved and these throw "no transaction"/"no such savepoint"; swallow so
|
|
116
|
+
* the original error propagates. */
|
|
117
|
+
function rollbackTxn(db: TxnCapableDb, frame: TxnFrame): void {
|
|
118
|
+
try {
|
|
119
|
+
if (frame.outermost) {
|
|
120
|
+
db.exec("ROLLBACK");
|
|
121
|
+
} else {
|
|
122
|
+
db.exec(`ROLLBACK TO ${frame.savepoint}`);
|
|
123
|
+
db.exec(`RELEASE ${frame.savepoint}`);
|
|
124
|
+
}
|
|
125
|
+
} catch (rollbackErr) {
|
|
126
|
+
// Only swallow the ONE expected case: the COMMIT/RELEASE itself already
|
|
127
|
+
// resolved the transaction, so there's nothing left to unwind — SQLite
|
|
128
|
+
// reports "cannot rollback - no transaction is active" / "no such
|
|
129
|
+
// savepoint". Any OTHER rollback failure is a real problem (a corrupt
|
|
130
|
+
// connection, a lost write lock) and must not be masked — re-throw it so
|
|
131
|
+
// the caller sees a failed rollback rather than a false success. Depth is
|
|
132
|
+
// restored first so the connection's bookkeeping stays consistent even on
|
|
133
|
+
// the re-throw path.
|
|
134
|
+
txnDepth.set(db, frame.priorDepth);
|
|
135
|
+
const msg = String((rollbackErr as Error)?.message ?? rollbackErr);
|
|
136
|
+
if (/no transaction is active|no such savepoint/i.test(msg)) return;
|
|
137
|
+
throw rollbackErr;
|
|
138
|
+
}
|
|
139
|
+
txnDepth.set(db, frame.priorDepth);
|
|
140
|
+
}
|
|
141
|
+
|
|
41
142
|
/**
|
|
42
143
|
* Run `fn` inside a single write transaction, committing its result or
|
|
43
144
|
* rolling back on throw. Synchronous — the callback must not await (see
|
|
@@ -55,20 +156,13 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
|
55
156
|
if (typeof db.transactionSync === "function") {
|
|
56
157
|
return db.transactionSync(fn);
|
|
57
158
|
}
|
|
58
|
-
db
|
|
159
|
+
const frame = enterTxn(db);
|
|
59
160
|
try {
|
|
60
161
|
const result = fn();
|
|
61
|
-
db
|
|
162
|
+
commitTxn(db, frame);
|
|
62
163
|
return result;
|
|
63
164
|
} catch (err) {
|
|
64
|
-
|
|
65
|
-
// already resolved and ROLLBACK would throw "no transaction is active";
|
|
66
|
-
// swallow that so the original error is the one that propagates.
|
|
67
|
-
try {
|
|
68
|
-
db.exec("ROLLBACK");
|
|
69
|
-
} catch {
|
|
70
|
-
// no active transaction to roll back
|
|
71
|
-
}
|
|
165
|
+
rollbackTxn(db, frame);
|
|
72
166
|
throw err;
|
|
73
167
|
}
|
|
74
168
|
}
|
|
@@ -86,20 +180,21 @@ export function transaction<T>(db: TxnCapableDb, fn: () => T): T {
|
|
|
86
180
|
* so it cannot wrap an `async` body that awaits the Store facade between writes.
|
|
87
181
|
* A DO backend that needs an atomic async batch has to reshape the batch as a
|
|
88
182
|
* synchronous unit (or use an async DO transaction primitive) — out of scope
|
|
89
|
-
* for this seam; the
|
|
183
|
+
* for this seam; the bun `BEGIN IMMEDIATE` path stays the single implementation.
|
|
184
|
+
*
|
|
185
|
+
* Re-entrant (vault#589): if the connection is already inside a transaction —
|
|
186
|
+
* e.g. the atomic blow-away import wraps this around store writes that
|
|
187
|
+
* themselves call {@link transaction} — a nested call opens a SAVEPOINT rather
|
|
188
|
+
* than a second `BEGIN`. See the file header.
|
|
90
189
|
*/
|
|
91
190
|
export async function transactionAsync<T>(db: TxnCapableDb, fn: () => Promise<T>): Promise<T> {
|
|
92
|
-
db
|
|
191
|
+
const frame = enterTxn(db);
|
|
93
192
|
try {
|
|
94
193
|
const result = await fn();
|
|
95
|
-
db
|
|
194
|
+
commitTxn(db, frame);
|
|
96
195
|
return result;
|
|
97
196
|
} catch (err) {
|
|
98
|
-
|
|
99
|
-
db.exec("ROLLBACK");
|
|
100
|
-
} catch {
|
|
101
|
-
// no active transaction to roll back
|
|
102
|
-
}
|
|
197
|
+
rollbackTxn(db, frame);
|
|
103
198
|
throw err;
|
|
104
199
|
}
|
|
105
200
|
}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -2983,6 +2983,12 @@ async function cmdImport(args: string[]) {
|
|
|
2983
2983
|
if (stats.skipped_attachments.length > 0) {
|
|
2984
2984
|
console.log(`Skipped ${stats.skipped_attachments.length} attachment(s) — see warnings above.`);
|
|
2985
2985
|
}
|
|
2986
|
+
if (stats.skipped_duplicate_ids.length > 0) {
|
|
2987
|
+
console.log(
|
|
2988
|
+
`Skipped ${stats.skipped_duplicate_ids.length} file(s) with duplicate/blank note id(s) — ` +
|
|
2989
|
+
`kept the first of each collision; see warnings above.`,
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2986
2992
|
return;
|
|
2987
2993
|
}
|
|
2988
2994
|
|
package/src/mirror-import.ts
CHANGED
|
@@ -523,6 +523,11 @@ function importResultFromStats(
|
|
|
523
523
|
`Dropped parent_names on tag "${sp.tag}": ${sp.reason}`,
|
|
524
524
|
);
|
|
525
525
|
}
|
|
526
|
+
for (const sd of stats.skipped_duplicate_ids) {
|
|
527
|
+
warnings.push(
|
|
528
|
+
`Skipped file (id "${sd.id}", path=${sd.path ?? "—"}): ${sd.reason}`,
|
|
529
|
+
);
|
|
530
|
+
}
|
|
526
531
|
const result: ImportResult = {
|
|
527
532
|
notes_imported: stats.notes_created + stats.notes_updated,
|
|
528
533
|
tags_imported: stats.schemas_restored,
|
package/src/routes.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
getContentWikilinkWarnings,
|
|
32
32
|
} from "../core/src/wikilinks.ts";
|
|
33
33
|
import { transactionAsync } from "../core/src/txn.ts";
|
|
34
|
-
import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, getVaultMap } from "../core/src/notes.ts";
|
|
34
|
+
import { getNote, getNotes, getNoteTags, getNoteByTitle, toNoteIndex, filterMetadata, mergeMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, PathConflictError, validatePath, PathValidationError, getVaultMap } from "../core/src/notes.ts";
|
|
35
35
|
import { normalizePath } from "../core/src/paths.ts";
|
|
36
36
|
import {
|
|
37
37
|
parseContentRange,
|
|
@@ -1811,6 +1811,9 @@ async function handleNotesInner(
|
|
|
1811
1811
|
const extension = item.extension !== undefined
|
|
1812
1812
|
? validateExtension(item.extension)
|
|
1813
1813
|
: undefined;
|
|
1814
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
1815
|
+
// Same batch-transaction throw semantics as extension validation.
|
|
1816
|
+
validatePath(item.path);
|
|
1814
1817
|
const effectiveExtension = extension ?? "md";
|
|
1815
1818
|
const ifExists: string = item.if_exists ?? "error";
|
|
1816
1819
|
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
@@ -1958,6 +1961,13 @@ async function handleNotesInner(
|
|
|
1958
1961
|
400,
|
|
1959
1962
|
);
|
|
1960
1963
|
}
|
|
1964
|
+
// Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
|
|
1965
|
+
if (e && e.code === "INVALID_PATH") {
|
|
1966
|
+
return json(
|
|
1967
|
+
{ error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
|
|
1968
|
+
400,
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1961
1971
|
throw e;
|
|
1962
1972
|
}
|
|
1963
1973
|
|
|
@@ -2253,6 +2263,9 @@ async function handleNotesInner(
|
|
|
2253
2263
|
const createExt = body.extension !== undefined
|
|
2254
2264
|
? validateExtension(body.extension)
|
|
2255
2265
|
: undefined;
|
|
2266
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
2267
|
+
// Covers both the explicit `path` and the idOrPath-as-path shape.
|
|
2268
|
+
validatePath(explicitPath ?? (idLooksLikePath ? idOrPathStr : undefined));
|
|
2256
2269
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
2257
2270
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPathStr } : { id: idOrPathStr, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
2258
2271
|
...(tagsArr.length > 0 ? { tags: tagsArr } : {}),
|
|
@@ -2499,7 +2512,12 @@ async function handleNotesInner(
|
|
|
2499
2512
|
if (body.append !== undefined) updates.append = body.append;
|
|
2500
2513
|
if (body.prepend !== undefined) updates.prepend = body.prepend;
|
|
2501
2514
|
}
|
|
2502
|
-
if (body.path !== undefined)
|
|
2515
|
+
if (body.path !== undefined) {
|
|
2516
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
2517
|
+
// Throws PathValidationError → 400 in the outer catch.
|
|
2518
|
+
validatePath(body.path);
|
|
2519
|
+
updates.path = body.path;
|
|
2520
|
+
}
|
|
2503
2521
|
if (body.extension !== undefined) {
|
|
2504
2522
|
// Validate up front (vault#328). Throws ExtensionValidationError
|
|
2505
2523
|
// which the outer catch converts to a 400.
|
|
@@ -2756,6 +2774,13 @@ async function handleNotesInner(
|
|
|
2756
2774
|
400,
|
|
2757
2775
|
);
|
|
2758
2776
|
}
|
|
2777
|
+
// Bad `path` value (vault#589 / FIX 2) — NUL byte or `..` segment.
|
|
2778
|
+
if (e && e.code === "INVALID_PATH") {
|
|
2779
|
+
return json(
|
|
2780
|
+
{ error_type: "invalid_path", error: "invalid_path", path: e.path, reason: e.reason, message: e.message },
|
|
2781
|
+
400,
|
|
2782
|
+
);
|
|
2783
|
+
}
|
|
2759
2784
|
throw e;
|
|
2760
2785
|
}
|
|
2761
2786
|
}
|
package/src/vault.test.ts
CHANGED
|
@@ -539,6 +539,37 @@ describe("MCP tools", async () => {
|
|
|
539
539
|
expect(result.tags).toContain("daily");
|
|
540
540
|
});
|
|
541
541
|
|
|
542
|
+
// FIX 2 (vault#589) — the MCP door rejects illegal paths too. The core tool
|
|
543
|
+
// throws a `PathValidationError` (error_type invalid_path) exactly like
|
|
544
|
+
// ExtensionValidationError; mcp-http.ts's generic error_type mapping turns it
|
|
545
|
+
// into a structured domain error at the transport. Assert the MCP tool path
|
|
546
|
+
// itself refuses the write (parity with the REST-door tests below).
|
|
547
|
+
test("create-note MCP tool rejects a '..' path with error_type invalid_path", async () => {
|
|
548
|
+
const tools = generateMcpTools(store);
|
|
549
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
550
|
+
let thrown: any;
|
|
551
|
+
try {
|
|
552
|
+
await createNote.execute({ content: "x", path: "../escape" });
|
|
553
|
+
} catch (e) { thrown = e; }
|
|
554
|
+
expect(thrown).toBeTruthy();
|
|
555
|
+
expect(thrown.error_type).toBe("invalid_path");
|
|
556
|
+
expect(thrown.code).toBe("INVALID_PATH");
|
|
557
|
+
// Nothing written.
|
|
558
|
+
expect(await store.getNoteByPath("escape")).toBeNull();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("create-note MCP tool rejects a NUL path with error_type invalid_path", async () => {
|
|
562
|
+
const NUL = String.fromCharCode(0);
|
|
563
|
+
const tools = generateMcpTools(store);
|
|
564
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
565
|
+
let thrown: any;
|
|
566
|
+
try {
|
|
567
|
+
await createNote.execute({ content: "x", path: `bad${NUL}path` });
|
|
568
|
+
} catch (e) { thrown = e; }
|
|
569
|
+
expect(thrown).toBeTruthy();
|
|
570
|
+
expect(thrown.error_type).toBe("invalid_path");
|
|
571
|
+
});
|
|
572
|
+
|
|
542
573
|
test("every tool has inputSchema and execute", () => {
|
|
543
574
|
const tools = generateMcpTools(store);
|
|
544
575
|
for (const tool of tools) {
|
|
@@ -2664,6 +2695,63 @@ describe("HTTP /notes", async () => {
|
|
|
2664
2695
|
expect(body.error_type).toBe("invalid_extension");
|
|
2665
2696
|
});
|
|
2666
2697
|
|
|
2698
|
+
// FIX 2 (vault#589) — a note path with a NUL byte or a `..` segment is
|
|
2699
|
+
// rejected at the write surface (400 invalid_path), never persisted. A
|
|
2700
|
+
// NUL-in-path note otherwise slips the export traversal guard and then
|
|
2701
|
+
// aborts the entire vault export; a `..` note is silently un-round-trippable.
|
|
2702
|
+
test("POST /notes rejects a NUL-byte path with 400 invalid_path (not 201)", async () => {
|
|
2703
|
+
const NUL = String.fromCharCode(0);
|
|
2704
|
+
const res = await handleNotes(
|
|
2705
|
+
mkReq("POST", "/notes", { content: "x", path: `bad${NUL}path` }),
|
|
2706
|
+
store,
|
|
2707
|
+
"",
|
|
2708
|
+
);
|
|
2709
|
+
expect(res.status).toBe(400);
|
|
2710
|
+
const body = await res.json() as any;
|
|
2711
|
+
expect(body.error_type).toBe("invalid_path");
|
|
2712
|
+
// Nothing was written.
|
|
2713
|
+
expect(await store.getNoteByPath("bad")).toBeNull();
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
test("POST /notes rejects a '..' path with 400 invalid_path", async () => {
|
|
2717
|
+
const res = await handleNotes(
|
|
2718
|
+
mkReq("POST", "/notes", { content: "x", path: "../escape" }),
|
|
2719
|
+
store,
|
|
2720
|
+
"",
|
|
2721
|
+
);
|
|
2722
|
+
expect(res.status).toBe(400);
|
|
2723
|
+
const body = await res.json() as any;
|
|
2724
|
+
expect(body.error_type).toBe("invalid_path");
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
test("POST /notes still accepts a legitimate path with dots (regression)", async () => {
|
|
2728
|
+
const res = await handleNotes(
|
|
2729
|
+
mkReq("POST", "/notes", { content: "ok", path: "Projects/v1.2/notes" }),
|
|
2730
|
+
store,
|
|
2731
|
+
"",
|
|
2732
|
+
);
|
|
2733
|
+
expect(res.status).toBeLessThan(400);
|
|
2734
|
+
const body = await res.json() as any;
|
|
2735
|
+
expect(body.path).toBe("Projects/v1.2/notes");
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
test("PATCH /notes/:id rejects a '..' path with 400 invalid_path", async () => {
|
|
2739
|
+
const note = await store.createNote("hi", { id: "path-bad", path: "p" });
|
|
2740
|
+
const res = await handleNotes(
|
|
2741
|
+
mkReq("PATCH", "/notes/path-bad", {
|
|
2742
|
+
path: "../../etc/passwd",
|
|
2743
|
+
if_updated_at: note.updatedAt,
|
|
2744
|
+
}),
|
|
2745
|
+
store,
|
|
2746
|
+
"/path-bad",
|
|
2747
|
+
);
|
|
2748
|
+
expect(res.status).toBe(400);
|
|
2749
|
+
const body = await res.json() as any;
|
|
2750
|
+
expect(body.error_type).toBe("invalid_path");
|
|
2751
|
+
// The note's original path is untouched.
|
|
2752
|
+
expect((await store.getNote("path-bad"))!.path).toBe("p");
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2667
2755
|
test("GET /notes?extension=csv filters by extension", async () => {
|
|
2668
2756
|
await store.createNote("md note", { path: "a" });
|
|
2669
2757
|
await store.createNote("csv note", { path: "b", extension: "csv" });
|