@ccmsg/cli 0.13.0 → 0.14.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/package.json +1 -1
- package/src/auth/admin.ts +5 -2
- package/src/auth/auth.ts +16 -16
- package/src/auth/records.ts +63 -41
- package/src/daemon/supervise.ts +15 -7
- package/src/files/containment.ts +42 -19
- package/src/files/files.ts +123 -104
- package/src/files/sandbox.ts +0 -0
- package/src/instance/instance.ts +37 -10
- package/src/instance/log.ts +29 -13
- package/src/kv/store.ts +104 -56
- package/src/launcher/launcher.ts +8 -5
- package/src/launcher/roots.ts +10 -7
- package/src/launcher/tree.ts +15 -12
- package/src/messaging/delivery.ts +28 -11
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +48 -18
- package/src/sessions/dump.ts +11 -9
- package/src/sessions/fork.ts +21 -14
- package/src/sessions/handlers.ts +9 -7
- package/src/sessions/items.ts +10 -8
- package/src/sessions/last-live.ts +28 -5
- package/src/sessions/registry.ts +40 -23
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/package.json
CHANGED
package/src/auth/admin.ts
CHANGED
|
@@ -51,7 +51,10 @@ export interface Administered {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/** Run one administrative request. */
|
|
54
|
-
export function handleAdmin(
|
|
54
|
+
export async function handleAdmin(
|
|
55
|
+
at: Administered,
|
|
56
|
+
request: AdminRequest,
|
|
57
|
+
): Promise<DispatchResult> {
|
|
55
58
|
const auth = at.auth;
|
|
56
59
|
try {
|
|
57
60
|
switch (request.admin) {
|
|
@@ -77,7 +80,7 @@ export function handleAdmin(at: Administered, request: AdminRequest): DispatchRe
|
|
|
77
80
|
case "passkey_list":
|
|
78
81
|
return reply(request.request_id, { credentials: auth.list() });
|
|
79
82
|
case "passkey_remove": {
|
|
80
|
-
const { closed } = auth.remove(request.sub);
|
|
83
|
+
const { closed } = await auth.remove(request.sub);
|
|
81
84
|
return reply(request.request_id, { sub: request.sub, closed });
|
|
82
85
|
}
|
|
83
86
|
}
|
package/src/auth/auth.ts
CHANGED
|
@@ -316,8 +316,8 @@ export class Auth {
|
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
/** Remove one person: the tombstones, and every connection they hold. */
|
|
319
|
-
remove(sub: Subject): { records: AuthRecord[]; closed: number } {
|
|
320
|
-
const records = this.deps.records.remove(sub);
|
|
319
|
+
async remove(sub: Subject): Promise<{ records: AuthRecord[]; closed: number }> {
|
|
320
|
+
const records = await this.deps.records.remove(sub);
|
|
321
321
|
return { records, closed: this.disconnect(sub) };
|
|
322
322
|
}
|
|
323
323
|
|
|
@@ -340,8 +340,8 @@ export class Auth {
|
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
/** Take what a peer wrote on `auth.records`, and act on the removals in it. */
|
|
343
|
-
merge(records: readonly AuthRecord[]): void {
|
|
344
|
-
const { removed } = this.deps.records.merge(records);
|
|
343
|
+
async merge(records: readonly AuthRecord[]): Promise<void> {
|
|
344
|
+
const { removed } = await this.deps.records.merge(records);
|
|
345
345
|
for (const sub of removed) this.disconnect(sub);
|
|
346
346
|
}
|
|
347
347
|
|
|
@@ -479,7 +479,7 @@ export class Auth {
|
|
|
479
479
|
...(from.ip === undefined ? {} : { registered_ip: from.ip }),
|
|
480
480
|
...(from.userAgent === undefined ? {} : { registered_user_agent: from.userAgent }),
|
|
481
481
|
};
|
|
482
|
-
this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
|
|
482
|
+
await this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
|
|
483
483
|
return this.mint(claims.sub);
|
|
484
484
|
}
|
|
485
485
|
|
|
@@ -589,7 +589,7 @@ export class Auth {
|
|
|
589
589
|
);
|
|
590
590
|
await this.#spendAnywhere(args.challenge);
|
|
591
591
|
const at = this.#now();
|
|
592
|
-
this.deps.records.write(
|
|
592
|
+
await this.deps.records.write(
|
|
593
593
|
credentialKey(record.sub, record.credential_id),
|
|
594
594
|
{
|
|
595
595
|
...record,
|
|
@@ -654,7 +654,7 @@ export class Auth {
|
|
|
654
654
|
// --- tokens (DR-0001 §2.4) ---
|
|
655
655
|
|
|
656
656
|
/** Make a family for this person, minted by this instance. */
|
|
657
|
-
mint(sub: Subject): MintedSession {
|
|
657
|
+
async mint(sub: Subject): Promise<MintedSession> {
|
|
658
658
|
const at = this.#now();
|
|
659
659
|
const family: TokenFamily = {
|
|
660
660
|
kind: "token_family",
|
|
@@ -664,7 +664,7 @@ export class Auth {
|
|
|
664
664
|
refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
|
|
665
665
|
};
|
|
666
666
|
const id = randomBytes(8).toString("hex");
|
|
667
|
-
this.deps.records.write(familyKey(sub, id), family, at);
|
|
667
|
+
await this.deps.records.write(familyKey(sub, id), family, at);
|
|
668
668
|
return { session: { sub, access: family.access }, refresh: family.refresh };
|
|
669
669
|
}
|
|
670
670
|
|
|
@@ -698,7 +698,7 @@ export class Auth {
|
|
|
698
698
|
} satisfies AuthRotateArgs)) as AuthRotateResult;
|
|
699
699
|
return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
|
|
700
700
|
}
|
|
701
|
-
const rotated = this.rotate(value, from);
|
|
701
|
+
const rotated = await this.rotate(value, from);
|
|
702
702
|
return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
|
|
703
703
|
}
|
|
704
704
|
|
|
@@ -714,7 +714,7 @@ export class Auth {
|
|
|
714
714
|
const owner = this.deps.records.owning(value, digestOf(value));
|
|
715
715
|
if (owner === undefined) return;
|
|
716
716
|
if (owner.body.iss === this.deps.self) {
|
|
717
|
-
this.#failReused(value);
|
|
717
|
+
await this.#failReused(value);
|
|
718
718
|
return;
|
|
719
719
|
}
|
|
720
720
|
try {
|
|
@@ -730,10 +730,10 @@ export class Auth {
|
|
|
730
730
|
|
|
731
731
|
/** Rotate a family this instance minted. The one writer's own operation, and
|
|
732
732
|
* what `auth.rotate` runs on its behalf. */
|
|
733
|
-
rotate(value: Base64Url, from: RefreshFrom = {}): AuthRotateResult {
|
|
733
|
+
async rotate(value: Base64Url, from: RefreshFrom = {}): Promise<AuthRotateResult> {
|
|
734
734
|
const held = this.deps.records.byRefresh(value);
|
|
735
735
|
if (held === undefined) {
|
|
736
|
-
this.#failReused(value);
|
|
736
|
+
await this.#failReused(value);
|
|
737
737
|
throw new OpError("auth_invalid", "この refresh token は使えません");
|
|
738
738
|
}
|
|
739
739
|
if (held.body.iss !== this.deps.self) {
|
|
@@ -777,7 +777,7 @@ export class Auth {
|
|
|
777
777
|
// holds wherever the reused value is presented (contract, `TokenFamily`).
|
|
778
778
|
retired: retire(held.body, at),
|
|
779
779
|
};
|
|
780
|
-
this.deps.records.write(held.key, rotated, at);
|
|
780
|
+
await this.deps.records.write(held.key, rotated, at);
|
|
781
781
|
return { sub: rotated.sub, access: rotated.access, refresh: rotated.refresh };
|
|
782
782
|
}
|
|
783
783
|
|
|
@@ -789,7 +789,7 @@ export class Auth {
|
|
|
789
789
|
* before it past its grace, and any generation this instance rotated away
|
|
790
790
|
* while it has been running. A value older than what any of those covers
|
|
791
791
|
* matches nothing and is refused as a stranger. */
|
|
792
|
-
#failReused(value: Base64Url): void {
|
|
792
|
+
async #failReused(value: Base64Url): Promise<void> {
|
|
793
793
|
const digest = digestOf(value);
|
|
794
794
|
const now = this.#now();
|
|
795
795
|
for (const held of this.deps.records.families()) {
|
|
@@ -805,7 +805,7 @@ export class Auth {
|
|
|
805
805
|
this.deps.log?.("a refresh token was reused after it was rotated away", {
|
|
806
806
|
sub: held.body.sub,
|
|
807
807
|
});
|
|
808
|
-
this.deps.records.fail(held.key);
|
|
808
|
+
await this.deps.records.fail(held.key);
|
|
809
809
|
// The tokens are gone, and so is what they were holding open: a
|
|
810
810
|
// connection that outlived the family it was admitted on would be the
|
|
811
811
|
// stolen token still working.
|
|
@@ -932,7 +932,7 @@ export function authHandlers(auth: Auth) {
|
|
|
932
932
|
// the URL and the count of tries against it (DR-0001 §2.2).
|
|
933
933
|
return { kind: "register", claims: auth.resolveRegistration(args.token, args.code) };
|
|
934
934
|
},
|
|
935
|
-
"auth.rotate": (input: HandlerInput): AuthRotateResult => {
|
|
935
|
+
"auth.rotate": (input: HandlerInput): Promise<AuthRotateResult> => {
|
|
936
936
|
const args = input.args as unknown as AuthRotateArgs;
|
|
937
937
|
// The receiving instance's account of the person, taken as stated: it is
|
|
938
938
|
// the only one that saw them, and `last_refresh` is a hint nothing is
|
package/src/auth/records.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
3
4
|
import type {
|
|
4
5
|
AuthRecord,
|
|
5
6
|
AuthTombstone,
|
|
@@ -81,9 +82,13 @@ export interface RecordsDeps {
|
|
|
81
82
|
* and in the authenticator, and losing a family logs its person out. */
|
|
82
83
|
export class AuthRecords {
|
|
83
84
|
readonly #records = new Map<string, AuthRecord>();
|
|
84
|
-
#loaded = false;
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
/** The writes already asked for, as one chain. */
|
|
87
|
+
#writing: Promise<void> = Promise.resolve();
|
|
88
|
+
|
|
89
|
+
constructor(private readonly deps: RecordsDeps) {
|
|
90
|
+
this.#read();
|
|
91
|
+
}
|
|
87
92
|
|
|
88
93
|
#now(): Timestamp {
|
|
89
94
|
return (this.deps.now ?? Date.now)();
|
|
@@ -94,7 +99,6 @@ export class AuthRecords {
|
|
|
94
99
|
* Answers whether the set moved, which is what decides whether the change is
|
|
95
100
|
* worth writing down and passing on. */
|
|
96
101
|
accept(record: AuthRecord): boolean {
|
|
97
|
-
this.#load();
|
|
98
102
|
const held = this.#records.get(record.key);
|
|
99
103
|
if (held !== undefined && held.updated_at >= record.updated_at) return false;
|
|
100
104
|
if (this.#refused(record)) return false;
|
|
@@ -135,13 +139,18 @@ export class AuthRecords {
|
|
|
135
139
|
* millisecond, which a rotation and the mint before it easily do, must not
|
|
136
140
|
* silently drop the second. So the instant is moved past what is held rather
|
|
137
141
|
* than compared against it. */
|
|
138
|
-
write(
|
|
139
|
-
|
|
142
|
+
async write(
|
|
143
|
+
key: string,
|
|
144
|
+
body: AuthRecord["body"],
|
|
145
|
+
now: Timestamp = this.#now(),
|
|
146
|
+
): Promise<boolean> {
|
|
140
147
|
const held = this.#records.get(key);
|
|
141
148
|
const at = held === undefined ? now : Math.max(now, held.updated_at + 1);
|
|
142
149
|
const record: AuthRecord = { key, updated_at: at, body };
|
|
143
150
|
if (!this.accept(record)) return false;
|
|
144
|
-
|
|
151
|
+
// Handed to the peers once it is written down, so no peer holds a record
|
|
152
|
+
// this instance would not have after a restart.
|
|
153
|
+
await this.#persist();
|
|
145
154
|
this.deps.publish([record]);
|
|
146
155
|
return true;
|
|
147
156
|
}
|
|
@@ -156,7 +165,7 @@ export class AuthRecords {
|
|
|
156
165
|
* This instance is its only writer, so a copy coming back is a copy of an
|
|
157
166
|
* older state — which is exactly what a failed family looks like from a peer
|
|
158
167
|
* that has not heard yet, and taking it would undo the failure. */
|
|
159
|
-
merge(records: readonly AuthRecord[]): { changed: number; removed: Subject[] } {
|
|
168
|
+
async merge(records: readonly AuthRecord[]): Promise<{ changed: number; removed: Subject[] }> {
|
|
160
169
|
let changed = 0;
|
|
161
170
|
const removed: Subject[] = [];
|
|
162
171
|
for (const record of records) {
|
|
@@ -165,7 +174,7 @@ export class AuthRecords {
|
|
|
165
174
|
changed += 1;
|
|
166
175
|
if (record.body.kind === "tombstone") removed.push(record.body.sub);
|
|
167
176
|
}
|
|
168
|
-
if (changed > 0) this.#persist();
|
|
177
|
+
if (changed > 0) await this.#persist();
|
|
169
178
|
return { changed, removed };
|
|
170
179
|
}
|
|
171
180
|
|
|
@@ -175,7 +184,7 @@ export class AuthRecords {
|
|
|
175
184
|
* lengths of time. A family expires with its refresh token, so the mark over
|
|
176
185
|
* it only has to outlive the longest one; a credential has no expiry of its
|
|
177
186
|
* own, so the mark over it has none either (DR-0001 §2.6). */
|
|
178
|
-
remove(sub: Subject): AuthRecord[] {
|
|
187
|
+
async remove(sub: Subject): Promise<AuthRecord[]> {
|
|
179
188
|
const at = this.#now();
|
|
180
189
|
const credential: AuthTombstone = { kind: "tombstone", sub, deleted_at: at };
|
|
181
190
|
const family: AuthTombstone = {
|
|
@@ -189,7 +198,7 @@ export class AuthRecords {
|
|
|
189
198
|
{ key: familyPrefix(sub), updated_at: at, body: family },
|
|
190
199
|
];
|
|
191
200
|
for (const mark of marks) this.accept(mark);
|
|
192
|
-
this.#persist();
|
|
201
|
+
await this.#persist();
|
|
193
202
|
this.deps.publish(marks);
|
|
194
203
|
return marks;
|
|
195
204
|
}
|
|
@@ -197,13 +206,11 @@ export class AuthRecords {
|
|
|
197
206
|
/** Whether this subject has been removed, which is what a registration for
|
|
198
207
|
* one has to be refused by. */
|
|
199
208
|
removed(sub: Subject): boolean {
|
|
200
|
-
this.#load();
|
|
201
209
|
const held = this.#records.get(credentialPrefix(sub));
|
|
202
210
|
return held?.body.kind === "tombstone";
|
|
203
211
|
}
|
|
204
212
|
|
|
205
213
|
credentials(): CredentialRecord[] {
|
|
206
|
-
this.#load();
|
|
207
214
|
const found: CredentialRecord[] = [];
|
|
208
215
|
for (const record of this.#records.values()) {
|
|
209
216
|
if (record.body.kind === "credential") found.push(record.body);
|
|
@@ -228,7 +235,6 @@ export class AuthRecords {
|
|
|
228
235
|
}
|
|
229
236
|
|
|
230
237
|
families(): { key: string; body: TokenFamily }[] {
|
|
231
|
-
this.#load();
|
|
232
238
|
const found: { key: string; body: TokenFamily }[] = [];
|
|
233
239
|
for (const record of this.#records.values()) {
|
|
234
240
|
if (record.body.kind === "token_family") found.push({ key: record.key, body: record.body });
|
|
@@ -275,12 +281,11 @@ export class AuthRecords {
|
|
|
275
281
|
* the key, which is exactly what a revoked family needs. It is kept for the
|
|
276
282
|
* same seven days a removal's is: past the longest refresh token, there is
|
|
277
283
|
* nothing left for a returning peer to revive. */
|
|
278
|
-
fail(key: string): void {
|
|
279
|
-
this.#load();
|
|
284
|
+
async fail(key: string): Promise<void> {
|
|
280
285
|
const held = this.#records.get(key);
|
|
281
286
|
if (held === undefined || held.body.kind !== "token_family") return;
|
|
282
287
|
const at = this.#now();
|
|
283
|
-
this.write(key, {
|
|
288
|
+
await this.write(key, {
|
|
284
289
|
kind: "tombstone",
|
|
285
290
|
sub: held.body.sub,
|
|
286
291
|
deleted_at: at,
|
|
@@ -306,7 +311,6 @@ export class AuthRecords {
|
|
|
306
311
|
|
|
307
312
|
/** Every record, for the snapshot a peer's subscription is answered with. */
|
|
308
313
|
all(): AuthRecord[] {
|
|
309
|
-
this.#load();
|
|
310
314
|
this.#expire();
|
|
311
315
|
return [...this.#records.values()];
|
|
312
316
|
}
|
|
@@ -330,9 +334,19 @@ export class AuthRecords {
|
|
|
330
334
|
}
|
|
331
335
|
}
|
|
332
336
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
337
|
+
/** Settle once every write asked for so far has landed. What a stop waits on
|
|
338
|
+
* before it lets go of the config home (DESIGN §8.5 step 4). */
|
|
339
|
+
async flush(): Promise<void> {
|
|
340
|
+
await this.#writing;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The file, read as this is built — before the instance is accepting
|
|
344
|
+
* anything, so nobody is waiting on it (DR-0015). Reading it when the first
|
|
345
|
+
* authentication asked would put the read inside the turn that answers it,
|
|
346
|
+
* and the `auth.records` snapshot is answered from what is held rather than
|
|
347
|
+
* from a promise. A file that is not there is an instance nobody has
|
|
348
|
+
* registered against, which is what an empty set says. */
|
|
349
|
+
#read(): void {
|
|
336
350
|
let text: string;
|
|
337
351
|
try {
|
|
338
352
|
text = readFileSync(this.#file(), "utf8");
|
|
@@ -356,21 +370,34 @@ export class AuthRecords {
|
|
|
356
370
|
this.#expire();
|
|
357
371
|
}
|
|
358
372
|
|
|
359
|
-
|
|
373
|
+
/** The set as it stands, written whole.
|
|
374
|
+
*
|
|
375
|
+
* The body is taken here, before anything is awaited, so what is written is
|
|
376
|
+
* the set as it was when the change was answered; the writes are chained so
|
|
377
|
+
* that two of them cannot be racing for one file and the older land last
|
|
378
|
+
* (DR-0015). */
|
|
379
|
+
#persist(): Promise<void> {
|
|
360
380
|
this.#expire();
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
381
|
+
const body = JSON.stringify([...this.#records.values()]);
|
|
382
|
+
const written = this.#writing.then(async () => {
|
|
383
|
+
await mkdir(this.deps.dir, { recursive: true, mode: 0o700 });
|
|
384
|
+
const file = this.#file();
|
|
385
|
+
const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
|
|
386
|
+
// The set holds tokens, so the file is the instance's own to read: it is
|
|
387
|
+
// created with the mode rather than fixed afterwards, so there is no
|
|
388
|
+
// instant at which it stands readable by anyone else.
|
|
389
|
+
await writeFile(temporary, body, { mode: 0o600 });
|
|
390
|
+
try {
|
|
391
|
+
await rename(temporary, file);
|
|
392
|
+
} catch (cause) {
|
|
393
|
+
await unlink(temporary);
|
|
394
|
+
throw cause;
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
// The chain carries the order, not the outcome: a write that failed is
|
|
398
|
+
// answered to its own caller, and the ones behind it still go.
|
|
399
|
+
this.#writing = written.catch(() => {});
|
|
400
|
+
return written;
|
|
374
401
|
}
|
|
375
402
|
|
|
376
403
|
#file(): string {
|
|
@@ -383,11 +410,6 @@ export function recordsDir(stateDir: string): string {
|
|
|
383
410
|
return join(stateDir, AUTH_DIR);
|
|
384
411
|
}
|
|
385
412
|
|
|
386
|
-
/** The parent of a path, made when a caller wants to write into it. */
|
|
387
|
-
export function ensureDir(file: string): void {
|
|
388
|
-
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
389
|
-
}
|
|
390
|
-
|
|
391
413
|
/** Whether an instance is the one allowed to write this family (DR-0001 §2.4). */
|
|
392
414
|
export function writes(family: TokenFamily, self: InstanceId): boolean {
|
|
393
415
|
return family.iss === self;
|
package/src/daemon/supervise.ts
CHANGED
|
@@ -275,18 +275,26 @@ export class Supervisor {
|
|
|
275
275
|
}
|
|
276
276
|
return await op(unit);
|
|
277
277
|
}
|
|
278
|
-
const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
|
|
279
278
|
// Taken as a list first: each step below waits, and a request arriving in
|
|
280
279
|
// between may add or remove one — what `--all` answers about is the set as
|
|
281
280
|
// it stood when it was asked.
|
|
282
281
|
const units = Array.from(this.#units.values());
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
282
|
+
// Every config home at once. Each is a child process of its own, and a
|
|
283
|
+
// start that waits on one of them serving is not a reason the next one has
|
|
284
|
+
// not been asked to start yet (DR-0015). The answers are still the units in
|
|
285
|
+
// the order they were taken, because that is the list the caller asked
|
|
286
|
+
// about.
|
|
287
|
+
const settled = await Promise.allSettled(units.map((unit) => op(unit)));
|
|
288
|
+
const answers: (T | { dir: string; error: { code: string; msg: string } })[] = [];
|
|
289
|
+
for (const [index, outcome] of settled.entries()) {
|
|
290
|
+
if (outcome.status === "fulfilled") {
|
|
291
|
+
answers.push(outcome.value);
|
|
292
|
+
continue;
|
|
289
293
|
}
|
|
294
|
+
const cause: unknown = outcome.reason;
|
|
295
|
+
if (!(cause instanceof CommandError)) throw cause;
|
|
296
|
+
const unit = units[index] as Supervised;
|
|
297
|
+
answers.push({ dir: unit.target.dir, error: { code: cause.code, msg: cause.message } });
|
|
290
298
|
}
|
|
291
299
|
return answers;
|
|
292
300
|
}
|
package/src/files/containment.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
4
|
import type { FileKind, Role, Sid } from "@ccmsg/protocol";
|
|
4
5
|
import { OpError } from "../dispatch/index.ts";
|
|
@@ -69,11 +70,11 @@ export class Containment {
|
|
|
69
70
|
constructor(private readonly source: RootsSource) {}
|
|
70
71
|
|
|
71
72
|
/** A path named by kind, as an op's arguments give it. */
|
|
72
|
-
locate(args: PathArgs, viewer: Viewer = {}): Located {
|
|
73
|
+
async locate(args: PathArgs, viewer: Viewer = {}): Promise<Located> {
|
|
73
74
|
const roots = this.rootsFor(args.sid, viewer);
|
|
74
|
-
const named = this.absolute(args, roots);
|
|
75
|
-
const real = canonical(named);
|
|
76
|
-
return { ...this.admit(args.kind, real, roots), named };
|
|
75
|
+
const named = await this.absolute(args, roots);
|
|
76
|
+
const real = await canonical(named);
|
|
77
|
+
return { ...(await this.admit(args.kind, real, roots)), named };
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** An absolute path with no kind: which surface admits it, if any.
|
|
@@ -81,7 +82,7 @@ export class Containment {
|
|
|
81
82
|
* The surfaces are tried in the order the contract states, and the answer is
|
|
82
83
|
* one value for every refusal — outside the allowlists, or simply not there —
|
|
83
84
|
* so a caller cannot learn from it whether a path it may not read exists. */
|
|
84
|
-
identify(sid: Sid, path: string, viewer: Viewer = {}): Located | undefined {
|
|
85
|
+
async identify(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located | undefined> {
|
|
85
86
|
let roots: SessionRoots;
|
|
86
87
|
try {
|
|
87
88
|
roots = this.rootsFor(sid, viewer);
|
|
@@ -90,10 +91,10 @@ export class Containment {
|
|
|
90
91
|
}
|
|
91
92
|
if (!isAbsolute(path)) return undefined;
|
|
92
93
|
const named = resolve(path);
|
|
93
|
-
const real = canonical(named);
|
|
94
|
+
const real = await canonical(named);
|
|
94
95
|
for (const kind of KINDS) {
|
|
95
96
|
try {
|
|
96
|
-
return { ...this.admit(kind, real, roots), named };
|
|
97
|
+
return { ...(await this.admit(kind, real, roots)), named };
|
|
97
98
|
} catch {
|
|
98
99
|
// The next surface may admit it; running out of surfaces is the miss.
|
|
99
100
|
}
|
|
@@ -106,15 +107,15 @@ export class Containment {
|
|
|
106
107
|
* fixed to (DR-0019). A name that leaves the inbox is refused as unwritable
|
|
107
108
|
* rather than as forbidden — the path is reachable, and only writing there
|
|
108
109
|
* is not. */
|
|
109
|
-
inbox(sid: Sid, path: string, viewer: Viewer = {}): Located {
|
|
110
|
+
async inbox(sid: Sid, path: string, viewer: Viewer = {}): Promise<Located> {
|
|
110
111
|
const roots = this.rootsFor(sid, viewer);
|
|
111
112
|
const cwd = roots.cwd;
|
|
112
113
|
if (cwd === undefined || !isAbsolute(cwd)) {
|
|
113
114
|
throw new OpError("path_forbidden", `${sid} states no working directory to write into`);
|
|
114
115
|
}
|
|
115
|
-
const base = canonical(cwd);
|
|
116
|
+
const base = await canonical(cwd);
|
|
116
117
|
const named = resolve(base, path);
|
|
117
|
-
const real = canonical(named);
|
|
118
|
+
const real = await canonical(named);
|
|
118
119
|
const inbox = join(base, INBOX);
|
|
119
120
|
if (!within(real, inbox)) {
|
|
120
121
|
throw new OpError("path_not_writable", `only ${INBOX}/ takes a written file`);
|
|
@@ -123,7 +124,7 @@ export class Containment {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
/** The directory a listing or a walk starts from. */
|
|
126
|
-
root(args: DirArgs, viewer: Viewer = {}): Located {
|
|
127
|
+
root(args: DirArgs, viewer: Viewer = {}): Promise<Located> {
|
|
127
128
|
return this.locate({ sid: args.sid, kind: args.kind, path: args.path ?? "" }, viewer);
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -142,13 +143,13 @@ export class Containment {
|
|
|
142
143
|
}
|
|
143
144
|
|
|
144
145
|
/** Turn an op's `path` into an absolute one, in the shape its kind states. */
|
|
145
|
-
private absolute(args: PathArgs, roots: SessionRoots): string {
|
|
146
|
+
private async absolute(args: PathArgs, roots: SessionRoots): Promise<string> {
|
|
146
147
|
if (args.kind === "contained") {
|
|
147
148
|
const root = roots.root;
|
|
148
149
|
if (root === undefined || !isAbsolute(root)) {
|
|
149
150
|
throw new OpError("path_forbidden", "this session states no root to be contained by");
|
|
150
151
|
}
|
|
151
|
-
return resolve(canonical(root), `.${sep}${args.path}`);
|
|
152
|
+
return resolve(await canonical(root), `.${sep}${args.path}`);
|
|
152
153
|
}
|
|
153
154
|
if (!isAbsolute(args.path)) {
|
|
154
155
|
throw new OpError("path_forbidden", `a ${args.kind} path is absolute`);
|
|
@@ -159,17 +160,21 @@ export class Containment {
|
|
|
159
160
|
/** Whether a resolved path is inside the surface it claims. The check runs on
|
|
160
161
|
* what the filesystem resolved, so a symlink pointing out of a root is
|
|
161
162
|
* refused however it was spelled (DR-0008 §3). */
|
|
162
|
-
private admit(
|
|
163
|
+
private async admit(
|
|
164
|
+
kind: FileKind,
|
|
165
|
+
real: string,
|
|
166
|
+
roots: SessionRoots,
|
|
167
|
+
): Promise<Omit<Located, "named">> {
|
|
163
168
|
if (kind === "contained") {
|
|
164
|
-
const root = roots.root === undefined ? undefined : canonical(roots.root);
|
|
169
|
+
const root = roots.root === undefined ? undefined : await canonical(roots.root);
|
|
165
170
|
if (root === undefined || !within(real, root)) {
|
|
166
171
|
throw new OpError("path_forbidden", "the path is outside the session's root");
|
|
167
172
|
}
|
|
168
173
|
return { kind, real, path: relativeTo(root, real) };
|
|
169
174
|
}
|
|
170
175
|
if (kind === "workspace") {
|
|
171
|
-
const
|
|
172
|
-
if (folder
|
|
176
|
+
const folders = await Promise.all(roots.workspace_folders.map(canonical));
|
|
177
|
+
if (!folders.some((folder) => within(real, folder))) {
|
|
173
178
|
throw new OpError("path_forbidden", "the path is in no workspace folder of this session");
|
|
174
179
|
}
|
|
175
180
|
return { kind, real, path: real };
|
|
@@ -179,7 +184,9 @@ export class Containment {
|
|
|
179
184
|
// path spelled through a symlink and the same file spelled directly are
|
|
180
185
|
// one entry, and a path since replaced by a symlink resolves elsewhere and
|
|
181
186
|
// is no longer in the list.
|
|
182
|
-
const named = roots.external_files.
|
|
187
|
+
const named = (await Promise.all(roots.external_files.map(canonical))).some(
|
|
188
|
+
(each) => each === real,
|
|
189
|
+
);
|
|
183
190
|
if (!named) {
|
|
184
191
|
throw new OpError("path_forbidden", "the path is not one this session's transcript named");
|
|
185
192
|
}
|
|
@@ -234,7 +241,23 @@ const KINDS = ["contained", "workspace", "external"] as const;
|
|
|
234
241
|
* last segment back, so a file about to be created is decided by where it would
|
|
235
242
|
* land rather than being refused for not being there. A parent that does not
|
|
236
243
|
* resolve either leaves the path as written, which no surface admits. */
|
|
237
|
-
export function canonical(path: string): string {
|
|
244
|
+
export async function canonical(path: string): Promise<string> {
|
|
245
|
+
const absolute = resolve(path);
|
|
246
|
+
try {
|
|
247
|
+
return await realpath(absolute);
|
|
248
|
+
} catch {
|
|
249
|
+
const parent = dirname(absolute);
|
|
250
|
+
if (parent === absolute) return absolute;
|
|
251
|
+
try {
|
|
252
|
+
return join(await realpath(parent), basename(absolute));
|
|
253
|
+
} catch {
|
|
254
|
+
return absolute;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The synchronous form required while `session.status` states its value synchronously (DESIGN §6); an asynchronous topic value uses `canonical` as the single path. */
|
|
260
|
+
export function canonicalSync(path: string): string {
|
|
238
261
|
const absolute = resolve(path);
|
|
239
262
|
try {
|
|
240
263
|
return realpathSync(absolute);
|