dsh-cindy-host 0.1.20
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/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- package/src/session-read-model.js +6 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { constants, promises as fs } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { createFilePeerConnection, sendFilePeerBinary } from './file-peer-rtc.js';
|
|
4
|
+
import { FILE_PEER_VERSION, FILE_PEER_MAX_BYTES, FILE_PEER_CHUNK_BYTES, FILE_PEER_CREDIT } from './file-peer-protocol.js';
|
|
5
|
+
|
|
6
|
+
const UUID = /^[a-f0-9-]{36}$/;
|
|
7
|
+
const MAX_BUFFERED = 1024 * 1024;
|
|
8
|
+
const fail = (code) => Object.assign(new Error(code), { code });
|
|
9
|
+
|
|
10
|
+
function parseRequest(value) {
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw fail('INVALID_FILE_PEER_REQUEST');
|
|
12
|
+
if (value.action === 'caps') return value;
|
|
13
|
+
if (value.action === 'offer' && typeof value.sdp === 'string' && value.sdp.length && value.sdp.length <= 128 * 1024) return value;
|
|
14
|
+
if (typeof value.connection !== 'string' || !UUID.test(value.connection)) throw fail('INVALID_FILE_PEER_REQUEST');
|
|
15
|
+
if (value.action === 'close') return value;
|
|
16
|
+
if (value.action === 'open' && typeof value.url === 'string' && value.url.length && value.url.length <= 16 * 1024) return value;
|
|
17
|
+
throw fail('INVALID_FILE_PEER_REQUEST');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameFile(a, b) {
|
|
21
|
+
// Metadata consistency, not a cryptographic snapshot of hostile storage.
|
|
22
|
+
return a?.isFile() && b?.isFile() && ['size', 'mtimeMs', 'dev', 'ino'].every((key) => a[key] !== undefined && a[key] === b[key]) && (a.ctimeMs === undefined || a.ctimeMs === b.ctimeMs);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Closing a werift connection or a FileHandle can itself reject. Never leak an
|
|
26
|
+
// unhandled rejection from an event callback or synchronous invalidation API.
|
|
27
|
+
function safelyClose(resource) {
|
|
28
|
+
try { return Promise.resolve(resource?.close()).catch(() => {}); }
|
|
29
|
+
catch { return Promise.resolve(); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Authorized, download-only Cindy files-v1 source. No whole-file buffering. */
|
|
33
|
+
export function createFilePeerManager({
|
|
34
|
+
resolveFile, isAllowed, loadIceServers,
|
|
35
|
+
createPeerConnection = createFilePeerConnection,
|
|
36
|
+
sendBinary = sendFilePeerBinary,
|
|
37
|
+
openFile = fs.open, idleMs = 60_000, maxConnections = 4, onEvent, identify,
|
|
38
|
+
}) {
|
|
39
|
+
if (![resolveFile, isAllowed, loadIceServers, createPeerConnection, sendBinary, openFile].every((fn) => typeof fn === 'function')) throw new TypeError('File peer dependencies must be functions');
|
|
40
|
+
if (!Number.isFinite(idleMs) || idleMs <= 0 || !Number.isSafeInteger(maxConnections) || maxConnections < 1 || maxConnections > 4) throw new TypeError('Invalid file peer limits');
|
|
41
|
+
const tags = (facts) => {
|
|
42
|
+
try {
|
|
43
|
+
const value = identify?.(facts);
|
|
44
|
+
if (value?.then) { void Promise.resolve(value).catch(() => {}); return {}; }
|
|
45
|
+
return Object.fromEntries(['actorId', 'ownerId', 'sourceId']
|
|
46
|
+
.filter(key => typeof value?.[key] === 'string' && /^[a-f0-9]{64}$/.test(value[key]))
|
|
47
|
+
.map(key => [key, value[key]]));
|
|
48
|
+
} catch { return {}; }
|
|
49
|
+
};
|
|
50
|
+
const observe = (c, event, fields = {}) => {
|
|
51
|
+
try {
|
|
52
|
+
c.tags ??= tags({ actor: c.peer });
|
|
53
|
+
const result = onEvent?.({ ...c.tags, ...c.source?.tags, transport: 'peer', event, connection: c.id, ...fields });
|
|
54
|
+
if (result?.then) void Promise.resolve(result).catch(() => {});
|
|
55
|
+
} catch { /* Observation never changes the peer contract. */ }
|
|
56
|
+
};
|
|
57
|
+
const connections = new Map();
|
|
58
|
+
let epoch = 0;
|
|
59
|
+
const allowed = (peer) => { try { return typeof peer === 'string' && peer.length > 0 && isAllowed(peer) === true; } catch { return false; } };
|
|
60
|
+
|
|
61
|
+
function release(source) {
|
|
62
|
+
if (!source) return Promise.resolve();
|
|
63
|
+
return source.closing ??= safelyClose(source.file);
|
|
64
|
+
}
|
|
65
|
+
function close(c, code = 'FILE_PEER_CLOSED') {
|
|
66
|
+
if (connections.get(c.id) !== c) return;
|
|
67
|
+
connections.delete(c.id); // Invalidate before calling external code.
|
|
68
|
+
observe(c, 'closed', { code });
|
|
69
|
+
clearTimeout(c.timer);
|
|
70
|
+
clearTimeout(c.offerTimer);
|
|
71
|
+
for (const cancel of c.waiters) cancel(fail(code));
|
|
72
|
+
c.waiters.clear();
|
|
73
|
+
c.finishIce?.();
|
|
74
|
+
const source = c.source;
|
|
75
|
+
c.source = null;
|
|
76
|
+
void release(source);
|
|
77
|
+
void safelyClose(c.dc);
|
|
78
|
+
void safelyClose(c.pc);
|
|
79
|
+
}
|
|
80
|
+
function current(c) {
|
|
81
|
+
if (connections.get(c.id) !== c || c.epoch !== epoch) throw fail('FILE_PEER_CLOSED');
|
|
82
|
+
if (!allowed(c.peer)) { close(c, 'FILE_PEER_REVOKED'); throw fail('FILE_PEER_REVOKED'); }
|
|
83
|
+
return c;
|
|
84
|
+
}
|
|
85
|
+
// Race invalidation as well as checking after every await. A factory/open
|
|
86
|
+
// that completes after cancellation must dispose its newly acquired resource.
|
|
87
|
+
function step(c, operation, dispose, adopt) {
|
|
88
|
+
// Remove each cancellation listener when settled: a single lifetime promise
|
|
89
|
+
// would retain one reaction per block until the entire 2 GiB stream closes.
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const cancel = (error) => { c.waiters.delete(cancel); reject(error); };
|
|
92
|
+
c.waiters.add(cancel);
|
|
93
|
+
Promise.resolve(operation).then((value) => {
|
|
94
|
+
try {
|
|
95
|
+
current(c);
|
|
96
|
+
adopt?.(value); // Register acquired resources before yielding again.
|
|
97
|
+
c.waiters.delete(cancel);
|
|
98
|
+
resolve(value);
|
|
99
|
+
} catch (error) { dispose?.(value); cancel(error); }
|
|
100
|
+
}, cancel);
|
|
101
|
+
// An injected RTC/sender may synchronously emit close before returning.
|
|
102
|
+
try { current(c); } catch (error) { cancel(error); }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function progress(c) {
|
|
106
|
+
current(c);
|
|
107
|
+
clearTimeout(c.timer);
|
|
108
|
+
c.timer = setTimeout(() => close(c, 'FILE_PEER_TIMEOUT'), idleMs);
|
|
109
|
+
c.timer.unref?.();
|
|
110
|
+
}
|
|
111
|
+
function assertSource(c, source) {
|
|
112
|
+
current(c);
|
|
113
|
+
if (c.source !== source) throw fail('FILE_PEER_CLOSED');
|
|
114
|
+
}
|
|
115
|
+
async function checkStat(c, source) {
|
|
116
|
+
assertSource(c, source);
|
|
117
|
+
const stat = await step(c, source.file.stat());
|
|
118
|
+
assertSource(c, source);
|
|
119
|
+
if (!sameFile(source.info, stat)) throw fail('FILE_PEER_CHANGED');
|
|
120
|
+
}
|
|
121
|
+
async function serve(c, channel, data) {
|
|
122
|
+
current(c);
|
|
123
|
+
if (c.dc !== channel || channel.readyState !== 'open' || c.busy || c.opening || !c.ready || typeof data !== 'string' || data.length > 256) throw fail('FILE_PEER_BLOCK');
|
|
124
|
+
const request = JSON.parse(data);
|
|
125
|
+
const source = c.source;
|
|
126
|
+
if (!request || !source || typeof request.ticket !== 'string' || !UUID.test(request.ticket) || request.ticket !== source.ticket || !Number.isSafeInteger(request.offset) || request.offset !== source.offset || request.credit !== FILE_PEER_CREDIT) throw fail('FILE_PEER_BLOCK');
|
|
127
|
+
const batch = Symbol('file-peer-batch');
|
|
128
|
+
c.busy = batch;
|
|
129
|
+
try {
|
|
130
|
+
for (let i = 0; i < FILE_PEER_CREDIT; i++) {
|
|
131
|
+
await checkStat(c, source);
|
|
132
|
+
assertSource(c, source);
|
|
133
|
+
const length = Math.min(FILE_PEER_CHUNK_BYTES, source.info.size - source.offset);
|
|
134
|
+
const buffer = Buffer.alloc(length);
|
|
135
|
+
let filled = 0;
|
|
136
|
+
while (filled < length) {
|
|
137
|
+
const { bytesRead } = await step(c, source.file.read(buffer, filled, length - filled, source.offset + filled));
|
|
138
|
+
assertSource(c, source);
|
|
139
|
+
if (!Number.isSafeInteger(bytesRead) || bytesRead <= 0 || bytesRead > length - filled) throw fail('FILE_PEER_CHANGED');
|
|
140
|
+
filled += bytesRead;
|
|
141
|
+
// Regular files may short-read without changing. Fill only this bounded
|
|
142
|
+
// block, rechecking identity after every read (also before the next).
|
|
143
|
+
await checkStat(c, source);
|
|
144
|
+
assertSource(c, source);
|
|
145
|
+
}
|
|
146
|
+
if (!length) {
|
|
147
|
+
// Finish disk cleanup before exposing EOF. Cindy may issue its next
|
|
148
|
+
// open as soon as EOF arrives, even before the RTC send promise settles.
|
|
149
|
+
await step(c, release(source));
|
|
150
|
+
assertSource(c, source);
|
|
151
|
+
c.source = null;
|
|
152
|
+
c.busy = false;
|
|
153
|
+
}
|
|
154
|
+
if (channel.readyState !== 'open' || !Number.isFinite(channel.bufferedAmount) || channel.bufferedAmount < 0 || channel.bufferedAmount + Math.max(1, length) > MAX_BUFFERED) throw fail('FILE_PEER_BLOCK');
|
|
155
|
+
await step(c, sendBinary(channel, buffer)); // Includes mandatory zero-byte binary EOF.
|
|
156
|
+
current(c);
|
|
157
|
+
if (channel.bufferedAmount > MAX_BUFFERED) throw fail('FILE_PEER_BLOCK');
|
|
158
|
+
progress(c); // Requests, failed opens and credit spam never refresh TTL.
|
|
159
|
+
if (!length) { observe(c, 'eof-queued', { ...source.tags, queuedBytes: source.offset }); return; }
|
|
160
|
+
assertSource(c, source);
|
|
161
|
+
source.offset += length;
|
|
162
|
+
observe(c, 'bytes-queued', { queuedBytes: source.offset });
|
|
163
|
+
}
|
|
164
|
+
} finally { if (c.busy === batch) c.busy = false; }
|
|
165
|
+
}
|
|
166
|
+
function dataChannel(c, channel) {
|
|
167
|
+
try {
|
|
168
|
+
current(c);
|
|
169
|
+
if (!channel || c.dc || channel.label !== 'files-v1' || channel.ordered !== true || channel.maxRetransmits != null || channel.maxPacketLifeTime != null) throw fail('FILE_PEER_BLOCK');
|
|
170
|
+
c.dc = channel;
|
|
171
|
+
channel.binaryType = 'arraybuffer';
|
|
172
|
+
const opened = () => {
|
|
173
|
+
if (connections.get(c.id) === c && !c.openObserved && channel.readyState === 'open') {
|
|
174
|
+
c.openObserved = true; observe(c, 'dc-open');
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
channel.onopen = opened;
|
|
178
|
+
opened();
|
|
179
|
+
channel.onmessage = ({ data }) => serve(c, channel, data).catch(() => close(c));
|
|
180
|
+
channel.onclose = () => close(c);
|
|
181
|
+
channel.onerror = () => close(c);
|
|
182
|
+
} catch {
|
|
183
|
+
void safelyClose(channel);
|
|
184
|
+
close(c);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function gatherIce(c) {
|
|
188
|
+
const pc = c.pc;
|
|
189
|
+
if (pc.iceGatheringState === 'complete') return Promise.resolve();
|
|
190
|
+
return new Promise((resolve) => {
|
|
191
|
+
const done = () => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
pc.onicegatheringstatechange = null;
|
|
194
|
+
c.finishIce = null;
|
|
195
|
+
resolve();
|
|
196
|
+
};
|
|
197
|
+
const timer = setTimeout(done, 4000);
|
|
198
|
+
timer.unref?.();
|
|
199
|
+
c.finishIce = done;
|
|
200
|
+
pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') done(); };
|
|
201
|
+
if (pc.iceGatheringState === 'complete') done();
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async function offer(peer, sdp) {
|
|
205
|
+
if (connections.size >= maxConnections) throw fail('FILE_PEER_BUSY');
|
|
206
|
+
const c = { id: randomUUID(), peer, epoch, source: null, pc: null, dc: null, ready: false, busy: false, opening: false };
|
|
207
|
+
c.waiters = new Set();
|
|
208
|
+
connections.set(c.id, c); // Reserve the slot before any asynchronous work.
|
|
209
|
+
progress(c);
|
|
210
|
+
// Cindy reserves 30s for file-peer RPC. werift awaits gathering inside
|
|
211
|
+
// setLocalDescription: a <3s config fetch plus UDP -> TCP TURN retries
|
|
212
|
+
// can exceed 15s even when usable local candidates already exist. Keep
|
|
213
|
+
// 5s for RPC delivery; the later gatherIce(4s) cannot bound that await.
|
|
214
|
+
c.offerTimer = setTimeout(() => close(c, 'FILE_PEER_TIMEOUT'), 25_000);
|
|
215
|
+
c.offerTimer.unref?.();
|
|
216
|
+
try {
|
|
217
|
+
const iceServers = await step(c, loadIceServers());
|
|
218
|
+
current(c);
|
|
219
|
+
await step(c, createPeerConnection({ iceServers }), safelyClose, (pc) => { c.pc = pc; });
|
|
220
|
+
current(c);
|
|
221
|
+
c.pc.ondatachannel = ({ channel }) => dataChannel(c, channel);
|
|
222
|
+
c.pc.onconnectionstatechange = () => {
|
|
223
|
+
try {
|
|
224
|
+
current(c);
|
|
225
|
+
if (['failed', 'closed', 'disconnected'].includes(c.pc.connectionState)) close(c);
|
|
226
|
+
} catch { close(c); }
|
|
227
|
+
};
|
|
228
|
+
await step(c, c.pc.setRemoteDescription({ type: 'offer', sdp }));
|
|
229
|
+
current(c);
|
|
230
|
+
const answer = await step(c, c.pc.createAnswer());
|
|
231
|
+
current(c);
|
|
232
|
+
await step(c, c.pc.setLocalDescription(answer));
|
|
233
|
+
current(c);
|
|
234
|
+
await step(c, gatherIce(c));
|
|
235
|
+
current(c);
|
|
236
|
+
if (!c.pc.localDescription?.sdp || c.pc.signalingState === 'closed') throw fail('FILE_PEER_CLOSED');
|
|
237
|
+
c.ready = true;
|
|
238
|
+
observe(c, 'offer-answer');
|
|
239
|
+
progress(c);
|
|
240
|
+
return { connection: c.id, sdp: c.pc.localDescription.sdp };
|
|
241
|
+
} catch (error) { close(c); throw error; }
|
|
242
|
+
finally { clearTimeout(c.offerTimer); }
|
|
243
|
+
}
|
|
244
|
+
async function open(c, url) {
|
|
245
|
+
current(c);
|
|
246
|
+
if (!c.ready || c.opening || c.source || c.busy) throw fail('FILE_PEER_BUSY');
|
|
247
|
+
c.opening = true;
|
|
248
|
+
let source;
|
|
249
|
+
try {
|
|
250
|
+
const resolved = await step(c, resolveFile(url));
|
|
251
|
+
current(c);
|
|
252
|
+
if (!resolved?.ok) throw fail(resolved?.code || 'FILE_PEER_DENIED');
|
|
253
|
+
const cap = Math.min(resolved.cap ?? FILE_PEER_MAX_BYTES, FILE_PEER_MAX_BYTES);
|
|
254
|
+
if (!resolved.info?.isFile() || !Number.isSafeInteger(resolved.info.size) || resolved.info.size < 0 || !Number.isFinite(cap) || resolved.info.size > cap) throw fail('FILE_PEER_SIZE');
|
|
255
|
+
await step(c, openFile(resolved.real, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)), safelyClose, (file) => {
|
|
256
|
+
source = { file, info: resolved.info, offset: 0, ticket: randomUUID(), tags: tags({ actor: c.peer, real: resolved.real, info: resolved.info }) };
|
|
257
|
+
c.source = source; // Track even while the initial stat is pending.
|
|
258
|
+
});
|
|
259
|
+
current(c);
|
|
260
|
+
await checkStat(c, source);
|
|
261
|
+
assertSource(c, source);
|
|
262
|
+
progress(c);
|
|
263
|
+
const mimeType = typeof resolved.mimeType === 'string' && /^[\w.+-]+\/[\w.+-]+$/.test(resolved.mimeType) ? resolved.mimeType : 'application/octet-stream';
|
|
264
|
+
observe(c, 'file-open', { size: source.info.size });
|
|
265
|
+
return { ticket: source.ticket, size: source.info.size, mimeType };
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (source) {
|
|
268
|
+
if (c.source === source) c.source = null;
|
|
269
|
+
void release(source);
|
|
270
|
+
}
|
|
271
|
+
throw error;
|
|
272
|
+
} finally { c.opening = false; }
|
|
273
|
+
}
|
|
274
|
+
async function handle(peer, payload) {
|
|
275
|
+
if (!allowed(peer)) { closePeer(peer); throw fail('FILE_PEER_DENIED'); }
|
|
276
|
+
const request = parseRequest(payload);
|
|
277
|
+
if (request.action === 'caps') return { version: FILE_PEER_VERSION, maxBytes: FILE_PEER_MAX_BYTES };
|
|
278
|
+
if (request.action === 'offer') return offer(peer, request.sdp);
|
|
279
|
+
const c = connections.get(request.connection);
|
|
280
|
+
if (!c || c.peer !== peer) throw fail('FILE_PEER_DENIED');
|
|
281
|
+
current(c);
|
|
282
|
+
if (request.action === 'close') { close(c); return { ok: true }; }
|
|
283
|
+
return open(c, request.url);
|
|
284
|
+
}
|
|
285
|
+
function closePeer(peer) { for (const c of connections.values()) if (c.peer === peer) close(c); }
|
|
286
|
+
function closeAll() { epoch++; for (const c of connections.values()) close(c); }
|
|
287
|
+
function pruneUnauthorized() { for (const c of connections.values()) if (!allowed(c.peer)) close(c, 'FILE_PEER_REVOKED'); }
|
|
288
|
+
return { handle, closePeer, closeAll, pruneUnauthorized };
|
|
289
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
/** Local workdir references only. The same resolver re-authorizes prepare/open/legacy reads. */
|
|
4
|
+
export function createLocalFileUrl({ resolveFile }) {
|
|
5
|
+
return async function fileUrl({ workdir, relPath } = {}) {
|
|
6
|
+
if (typeof workdir !== 'string' || !isAbsolute(workdir) || workdir.includes('\0'))
|
|
7
|
+
return { ok: false, code: 'BAD_REQUEST', message: 'fileUrl requires a local absolute workdir; SSH export is not supported' };
|
|
8
|
+
if (typeof relPath !== 'string' || !relPath.trim() || relPath.includes('\0') || isAbsolute(relPath) || /^[A-Za-z]:/.test(relPath) || /^[\\/]/.test(relPath))
|
|
9
|
+
return { ok: false, code: 'BAD_REQUEST', message: 'relPath must be relative to the workdir' };
|
|
10
|
+
const url = new URL('xdt-file://open');
|
|
11
|
+
url.searchParams.set('path', resolve(workdir, relPath));
|
|
12
|
+
url.searchParams.set('workdir', workdir);
|
|
13
|
+
const authorized = await resolveFile(url.toString());
|
|
14
|
+
return authorized.ok === true ? { ok: true, url: url.toString() } : authorized;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `fs:*` and `text-file:*` reads the Cindy controller uses to browse files.
|
|
3
|
+
*
|
|
4
|
+
* DSH's filesystem service (`ctx.fs`) works in `FsTarget` handles — resolve a
|
|
5
|
+
* path, then stat/list/read the handle — while the controller speaks plain
|
|
6
|
+
* absolute paths and its own vocabulary (`'dir' | 'file' | 'missing'`). This
|
|
7
|
+
* module is exactly that translation, and nothing else: no policy, no caching,
|
|
8
|
+
* no path rewriting. Whatever the composed filesystem refuses (sandbox policy,
|
|
9
|
+
* a missing path) is reported to the controller as its own failure value rather
|
|
10
|
+
* than as an exception, because the controller renders those reasons.
|
|
11
|
+
*
|
|
12
|
+
* Contract shapes (`apps/mobile/src/device-link/mobileMakerTransport.ts`):
|
|
13
|
+
*
|
|
14
|
+
* fs:stat-path -> { kind: 'dir' | 'file' | 'missing', resolvedPath }
|
|
15
|
+
* fs:list-dir -> { resolvedPath, entries: [{ name, kind, path }], parent }
|
|
16
|
+
* text-file:read-preview -> { success, data?, size, reason? }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Longest preview this Host will inline; larger files report `oversize`. */
|
|
20
|
+
export const PREVIEW_LIMIT_BYTES = 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Map a DSH file type onto the controller's path-kind vocabulary.
|
|
24
|
+
*
|
|
25
|
+
* `'other'` is not a directory and does exist, so it reads as a file; only an
|
|
26
|
+
* absent `FsInfo` is `'missing'`.
|
|
27
|
+
*/
|
|
28
|
+
function pathKindOf(info) {
|
|
29
|
+
if (info === undefined || info === null) return 'missing';
|
|
30
|
+
return info.type === 'directory' ? 'dir' : 'file';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Map a DSH directory entry onto the controller's entry kind. */
|
|
34
|
+
function entryKindOf(type) {
|
|
35
|
+
if (type === 'directory') return 'dir';
|
|
36
|
+
if (type === 'file') return 'file';
|
|
37
|
+
return 'symlink';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The parent directory of a path, or null at a filesystem root.
|
|
42
|
+
*
|
|
43
|
+
* A Windows drive root keeps its separator: the parent of `G:\nope` is `G:\`,
|
|
44
|
+
* not `G:` — Windows resolves the latter as the *current* directory on that
|
|
45
|
+
* drive, so an "up one level" built from it lands somewhere the user never was.
|
|
46
|
+
*/
|
|
47
|
+
function parentOf(path) {
|
|
48
|
+
const normalised = String(path).replace(/[\\/]+$/, '');
|
|
49
|
+
const cut = Math.max(normalised.lastIndexOf('/'), normalised.lastIndexOf('\\'));
|
|
50
|
+
// No separator left after stripping: a bare name, or a root itself.
|
|
51
|
+
if (cut < 0) return null;
|
|
52
|
+
// A separator at index 0 is the POSIX root, which is its children's parent.
|
|
53
|
+
if (cut === 0) return '/';
|
|
54
|
+
const head = normalised.slice(0, cut);
|
|
55
|
+
return /^[A-Za-z]:$/.test(head) ? `${head}\\` : head;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the file reads over one filesystem service.
|
|
60
|
+
* @param options - the filesystem service and an injectable preview limit.
|
|
61
|
+
* @returns the three readers the router exposes.
|
|
62
|
+
*/
|
|
63
|
+
export function createFileReader({ fileSystem, previewLimitBytes = PREVIEW_LIMIT_BYTES }) {
|
|
64
|
+
if (fileSystem === undefined || fileSystem === null) throw new Error('createFileReader requires a filesystem service');
|
|
65
|
+
|
|
66
|
+
/** Resolve a controller-supplied path, or null when it does not resolve. */
|
|
67
|
+
async function resolvePath(path) {
|
|
68
|
+
if (typeof path !== 'string' || path.trim() === '') return null;
|
|
69
|
+
try {
|
|
70
|
+
return await fileSystem.resolve(path);
|
|
71
|
+
} catch {
|
|
72
|
+
// An unresolvable path is reported as `missing`/`not_found` below, not
|
|
73
|
+
// thrown: the controller has a rendering for that and none for a crash.
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `fs:stat-path` */
|
|
79
|
+
async function statPath(path) {
|
|
80
|
+
const target = await resolvePath(path);
|
|
81
|
+
if (target === null) return { kind: 'missing', resolvedPath: String(path ?? '') };
|
|
82
|
+
let info;
|
|
83
|
+
try {
|
|
84
|
+
info = await fileSystem.stat(target);
|
|
85
|
+
} catch {
|
|
86
|
+
info = undefined;
|
|
87
|
+
}
|
|
88
|
+
return { kind: pathKindOf(info), resolvedPath: fileSystem.processPath(target) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `fs:list-dir` */
|
|
92
|
+
async function listDir(path) {
|
|
93
|
+
const target = await resolvePath(path);
|
|
94
|
+
if (target === null) return { resolvedPath: String(path ?? ''), entries: [], parent: null };
|
|
95
|
+
let raw = [];
|
|
96
|
+
try {
|
|
97
|
+
raw = await fileSystem.listDir(target);
|
|
98
|
+
} catch {
|
|
99
|
+
raw = [];
|
|
100
|
+
}
|
|
101
|
+
const resolvedPath = fileSystem.processPath(target);
|
|
102
|
+
const entries = [];
|
|
103
|
+
for (const entry of Array.isArray(raw) ? raw : []) {
|
|
104
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
105
|
+
let entryPath = null;
|
|
106
|
+
try {
|
|
107
|
+
entryPath = fileSystem.processPath(entry.target);
|
|
108
|
+
} catch {
|
|
109
|
+
entryPath = null;
|
|
110
|
+
}
|
|
111
|
+
// The controller renders `path`, so an entry whose path cannot be named is
|
|
112
|
+
// dropped rather than emitted with a hole in it.
|
|
113
|
+
if (entryPath === null) continue;
|
|
114
|
+
entries.push({ name: String(entry.name ?? ''), kind: entryKindOf(entry.type), path: entryPath });
|
|
115
|
+
}
|
|
116
|
+
return { resolvedPath, entries, parent: parentOf(resolvedPath) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `text-file:read-preview` */
|
|
120
|
+
async function readTextPreview(filePath) {
|
|
121
|
+
const target = await resolvePath(filePath);
|
|
122
|
+
if (target === null) return { success: false, reason: 'not_found', size: 0 };
|
|
123
|
+
|
|
124
|
+
let info;
|
|
125
|
+
try {
|
|
126
|
+
info = await fileSystem.stat(target);
|
|
127
|
+
} catch {
|
|
128
|
+
info = undefined;
|
|
129
|
+
}
|
|
130
|
+
if (info === undefined || info === null) return { success: false, reason: 'not_found', size: 0 };
|
|
131
|
+
if (info.type === 'directory') return { success: false, reason: 'not_found', size: 0 };
|
|
132
|
+
|
|
133
|
+
const size = Number.isFinite(info.size) ? info.size : 0;
|
|
134
|
+
if (size > previewLimitBytes) {
|
|
135
|
+
return { success: false, reason: 'oversize', size, limitMb: Math.floor(previewLimitBytes / (1024 * 1024)) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let bytes;
|
|
139
|
+
try {
|
|
140
|
+
bytes = await fileSystem.readByteRange(target, { offset: 0, length: previewLimitBytes });
|
|
141
|
+
} catch {
|
|
142
|
+
return { success: false, reason: 'read_failed', size };
|
|
143
|
+
}
|
|
144
|
+
return { success: true, data: Buffer.from(bytes).toString('utf8'), size: bytes.length };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { statPath, listDir, readTextPreview };
|
|
148
|
+
}
|