@feng0531/dsh-vcs 0.4.9 → 0.4.10

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/index.mjs CHANGED
@@ -5,6 +5,7 @@ const runningFrontend=JSON.parse(readFileSync(new URL('./dist/frontend.json',imp
5
5
  import * as adapter from './vcs.mjs';
6
6
  import {runtimeInfo,checkUpdate} from './dist/updates.mjs';
7
7
  import {createSvnHttpsHost} from './dist/svn-host.mjs';
8
+ import {historySearchArgs,validateDateBound,isObjectId,isChangeId} from './git-history.mjs';
8
9
 
9
10
  export const name = 'dsh-vcs';
10
11
  export const inject = ['connection', 'webServer', 'sessions', 'sessionPersistence'];
@@ -27,8 +28,6 @@ export async function resolveSessionCwd(ctx, sessionId, signal) {
27
28
 
28
29
  const rediscover = () => Object.assign(new Error('Repository authorization is missing, expired, or changed. Rediscover repositories for this Session and retry.'), { code: 'vcs/rediscover-required' });
29
30
 
30
- import {historySearchArgs,validateDateBound} from './git-history.mjs';
31
-
32
31
  // Exact request fields are a protocol contract, independent of dispatch.
33
32
  const REQUEST_FIELDS = {
34
33
  'vcs/repositories': ['sessionId', 'subdirectory'],
@@ -54,51 +53,150 @@ const REQUEST_FIELDS = {
54
53
  'vcs/commit-image': ['sessionId', 'repositoryId', 'commit', 'parentIndex', 'id', 'side'],
55
54
  'vcs/workspace-image': ['sessionId', 'repositoryId', 'mode', 'id', 'side'],
56
55
  };
56
+
57
+ /** The supported endpoint list and each field whitelist derive from REQUEST_FIELDS. */
58
+ const ACTION_ENDPOINTS = new Set(Object.keys(REQUEST_FIELDS));
59
+ const REQUEST_FIELD_SETS = new Map(Object.entries(REQUEST_FIELDS).map(([endpoint, fields]) => [endpoint, new Set(fields)]));
60
+ const UPDATE_ENDPOINTS = new Set(['vcs/runtime-info', 'vcs/check-update']);
61
+
62
+ // Only repositories, SVN identity, workspace status and workspace comparison can
63
+ // address SVN working copies; every other action reads immutable Git history.
64
+ const SVN_CAPABLE_ENDPOINTS = new Set(['vcs/repositories', 'vcs/svn-identity', 'vcs/status', 'vcs/compare']);
65
+ const GIT_ONLY_ENDPOINTS = new Set([...ACTION_ENDPOINTS].filter(endpoint => !SVN_CAPABLE_ENDPOINTS.has(endpoint)));
66
+
67
+ // Named endpoint groups keep every request-shape and capacity decision in one place.
68
+ const HISTORICAL_PATH_ENDPOINTS = new Set(['vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history']);
69
+ const REVISION_PAIR_ENDPOINTS = new Set(['vcs/revision-changes', 'vcs/revision-compare', 'vcs/revision-image']);
70
+ const REVISION_CHANGE_ID_ENDPOINTS = new Set(['vcs/revision-compare', 'vcs/revision-image']);
71
+ const SELECTED_CHANGE_ENDPOINTS = new Set(['vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/commit-image']);
72
+ const COMMIT_ENDPOINTS = new Set(['vcs/commit', 'vcs/commit-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history', 'vcs/commit-image']);
73
+ const LARGE_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/commit-compare', 'vcs/revision-compare']);
74
+ const HISTORY_PATH_ENDPOINTS = new Set(['vcs/file-history', 'vcs/tree-history']);
75
+ const PAGINATED_HISTORY_ENDPOINTS = new Set(['vcs/history', 'vcs/file-history', 'vcs/tree-history']);
76
+ const IMAGE_ENDPOINTS = new Set(['vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image']);
77
+ const WORKSPACE_CHANGE_ENDPOINTS = new Set(['vcs/workspace-image', 'vcs/conflict-stages']);
78
+ const CHANGE_ID_COMPARISON_ENDPOINTS = new Set(['vcs/compare', 'vcs/workspace-image']);
79
+ const IMAGE_SIDES = new Set(['left', 'right']);
80
+ const COMPARISON_MODES = new Set(['all', 'unstaged', 'staged']);
81
+
82
+ /** Repositories requests may only narrow discovery to a relative Session subdirectory. */
83
+ function validateRepositoriesPayload(payload) {
84
+ if (payload.subdirectory === undefined) return;
85
+ const dir = payload.subdirectory;
86
+ if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
87
+ }
88
+
89
+ /** Historical paths are repository-relative and can never be absolute or escape. */
90
+ function validateHistoricalPath(endpoint, path) {
91
+ if (!HISTORICAL_PATH_ENDPOINTS.has(endpoint)) return;
92
+ if (typeof path !== 'string' || !path || path.length > 32768 || path.includes('\0') || path.startsWith('/') || path.split('/').some(part => !part || part === '.' || part === '..')) throw invalid('Invalid historical file path.');
93
+ }
94
+
95
+ function validateImageSide(endpoint, side) {
96
+ if (IMAGE_ENDPOINTS.has(endpoint) && side !== undefined && !IMAGE_SIDES.has(side)) throw invalid('Invalid image side.');
97
+ }
98
+
99
+ function validateRevisionSelection(endpoint, payload) {
100
+ if (!REVISION_PAIR_ENDPOINTS.has(endpoint)) return;
101
+ for (const oid of [payload.base, payload.target]) if (typeof oid !== 'string' || !isObjectId(oid)) throw invalid('Invalid revision commit id.');
102
+ if (REVISION_CHANGE_ID_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid revision change id.');
103
+ }
104
+
105
+ function validateSelectedChangeId(endpoint, payload) {
106
+ if (SELECTED_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid historical change id.');
107
+ }
108
+
109
+ function validateCommitSelection(endpoint, payload) {
110
+ if (!COMMIT_ENDPOINTS.has(endpoint)) return;
111
+ if (typeof payload.commit !== 'string' || !isObjectId(payload.commit)) throw invalid('Invalid commit id.');
112
+ if (payload.parentIndex !== undefined && (!Number.isInteger(payload.parentIndex) || payload.parentIndex < 0 || payload.parentIndex > 100)) throw invalid('Invalid parent index.');
113
+ }
114
+
115
+ function validateLargeComparison(endpoint, payload) {
116
+ if (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large !== undefined && typeof payload.large !== 'boolean') throw invalid('Invalid large comparison mode.');
117
+ }
118
+
119
+ function validateBlameWindow(endpoint, payload) {
120
+ if (endpoint !== 'vcs/blame') return;
121
+ const startInvalid = payload.startLine !== undefined && (!Number.isInteger(payload.startLine) || payload.startLine < 1 || payload.startLine > 100001);
122
+ const limitInvalid = payload.lineLimit !== undefined && (!Number.isInteger(payload.lineLimit) || payload.lineLimit < 1 || payload.lineLimit > 500);
123
+ if (startInvalid || limitInvalid) throw invalid('Invalid blame line window.');
124
+ }
125
+
126
+ function validateContentSearch(endpoint, payload) {
127
+ if (endpoint !== 'vcs/grep') return;
128
+ if (typeof payload.query !== 'string' || !payload.query || payload.query.length > 1024 || /[\0\r\n]/.test(payload.query)) throw invalid('Invalid content search query.');
129
+ if (payload.commit !== undefined && (typeof payload.commit !== 'string' || !isObjectId(payload.commit))) throw invalid('Invalid content search commit.');
130
+ }
131
+
132
+ function validateRenameFollow(endpoint, payload) {
133
+ if (HISTORY_PATH_ENDPOINTS.has(endpoint) && payload.follow !== undefined && typeof payload.follow !== 'boolean') throw invalid('Invalid rename follow mode.');
134
+ }
135
+
136
+ function validateHistoryDates(endpoint, payload) {
137
+ if (!HISTORY_PATH_ENDPOINTS.has(endpoint) || (payload.since === undefined && payload.until === undefined)) return;
138
+ try {
139
+ if (payload.since !== undefined) validateDateBound(payload.since);
140
+ if (payload.until !== undefined) validateDateBound(payload.until);
141
+ } catch { throw invalid('Invalid history date bound.'); }
142
+ }
143
+
144
+ function validateHistoryPagination(endpoint, payload) {
145
+ if (!PAGINATED_HISTORY_ENDPOINTS.has(endpoint)) return;
146
+ if (payload.offset === null || payload.limit === null) throw invalid('Invalid history pagination.');
147
+ if (payload.snapshot !== undefined && (typeof payload.snapshot !== 'string' || !isObjectId(payload.snapshot))) throw invalid('Invalid history snapshot.');
148
+ const offset = payload.offset ?? 0, limit = payload.limit ?? 50;
149
+ if (!Number.isInteger(offset) || offset < 0 || offset > 10000 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw invalid('Invalid history pagination.');
150
+ }
151
+
152
+ function validateWorkspaceChangeId(endpoint, payload) {
153
+ if (WORKSPACE_CHANGE_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !isChangeId(payload.id))) throw invalid('Invalid workspace image change id.');
154
+ }
155
+
156
+ function validateModeAndComparisonId(endpoint, payload) {
157
+ if (!COMPARISON_MODES.has(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
158
+ if (CHANGE_ID_COMPARISON_ENDPOINTS.has(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
159
+ }
160
+
161
+ /** The validation order below is part of the protocol: the first failing group wins. */
162
+ function validateRepositoryPayload(endpoint, payload) {
163
+ if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
164
+ validateHistoricalPath(endpoint, payload.path);
165
+ validateImageSide(endpoint, payload.side);
166
+ validateRevisionSelection(endpoint, payload);
167
+ validateSelectedChangeId(endpoint, payload);
168
+ validateCommitSelection(endpoint, payload);
169
+ validateLargeComparison(endpoint, payload);
170
+ if (endpoint === 'vcs/tree-segment' && payload.offset !== undefined && (!Number.isInteger(payload.offset) || payload.offset < 0 || payload.offset > 16777216)) throw invalid('Invalid segment offset.');
171
+ validateBlameWindow(endpoint, payload);
172
+ validateContentSearch(endpoint, payload);
173
+ validateRenameFollow(endpoint, payload);
174
+ validateHistoryDates(endpoint, payload);
175
+ if (endpoint === 'vcs/history') { try { historySearchArgs(payload.search); } catch { throw invalid('Invalid history search.'); } }
176
+ validateHistoryPagination(endpoint, payload);
177
+ validateWorkspaceChangeId(endpoint, payload);
178
+ validateModeAndComparisonId(endpoint, payload);
179
+ }
180
+
57
181
  function validatePayload(endpoint, payload) {
58
182
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw invalid('An object payload is required.');
59
- const fields = REQUEST_FIELDS[endpoint];
60
- if (Object.keys(payload).some(key => !fields.includes(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
61
- if (endpoint === 'vcs/repositories') {
62
- if (payload.subdirectory !== undefined) {
63
- const dir = payload.subdirectory;
64
- if (typeof dir !== 'string' || !dir || dir.length > 4096 || /[\0:]/.test(dir) || /^[\/\\]/.test(dir) || dir.split(/[\/\\]/).includes('..')) throw invalid('subdirectory must be a relative path within the Session directory.');
65
- }
66
- } else {
67
- if (typeof payload.repositoryId !== 'string' || !payload.repositoryId || payload.repositoryId.length > 1024) throw invalid('A discovered repositoryId is required.');
68
- if(['vcs/tree-file','vcs/tree-segment','vcs/tree-history'].includes(endpoint)&&(typeof payload.path!=='string'||!payload.path||payload.path.length>32768||payload.path.includes('\0')||payload.path.startsWith('/')||payload.path.split('/').some(part=>!part||part==='.'||part==='..')))throw invalid('Invalid historical file path.');
69
- if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)&&payload.side!==undefined&&!['left','right'].includes(payload.side))throw invalid('Invalid image side.');
70
- if(['vcs/revision-changes','vcs/revision-compare','vcs/revision-image'].includes(endpoint)){
71
- for(const oid of [payload.base,payload.target])if(typeof oid!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid))throw invalid('Invalid revision commit id.');
72
- if(['vcs/revision-compare','vcs/revision-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid revision change id.');
73
- }
74
- if(['vcs/commit-compare','vcs/file-history','vcs/blame','vcs/commit-image'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid historical change id.');
75
- if(['vcs/commit','vcs/commit-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/tree-history','vcs/commit-image'].includes(endpoint)){
76
- if(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit))throw invalid('Invalid commit id.');
77
- if(payload.parentIndex!==undefined&&(!Number.isInteger(payload.parentIndex)||payload.parentIndex<0||payload.parentIndex>100))throw invalid('Invalid parent index.');
78
- }
79
- if(['vcs/compare','vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large!==undefined&&typeof payload.large!=='boolean')throw invalid('Invalid large comparison mode.');
80
- if(endpoint==='vcs/tree-segment'&&payload.offset!==undefined&&(!Number.isInteger(payload.offset)||payload.offset<0||payload.offset>16777216))throw invalid('Invalid segment offset.');
81
- if(endpoint==='vcs/blame'&&((payload.startLine!==undefined&&(!Number.isInteger(payload.startLine)||payload.startLine<1||payload.startLine>100001))||(payload.lineLimit!==undefined&&(!Number.isInteger(payload.lineLimit)||payload.lineLimit<1||payload.lineLimit>500))))throw invalid('Invalid blame line window.');
82
- if(endpoint==='vcs/grep'){if(typeof payload.query!=='string'||!payload.query||payload.query.length>1024||/[\0\r\n]/.test(payload.query))throw invalid('Invalid content search query.');if(payload.commit!==undefined&&(typeof payload.commit!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.commit)))throw invalid('Invalid content search commit.');}
83
- if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&payload.follow!==undefined&&typeof payload.follow!=='boolean')throw invalid('Invalid rename follow mode.');
84
- if(['vcs/file-history','vcs/tree-history'].includes(endpoint)&&(payload.since!==undefined||payload.until!==undefined)){try{if(payload.since!==undefined)validateDateBound(payload.since);if(payload.until!==undefined)validateDateBound(payload.until);}catch{throw invalid('Invalid history date bound.');}}
85
- if(endpoint==='vcs/history'){try{historySearchArgs(payload.search);}catch{throw invalid('Invalid history search.');}}
86
- if(['vcs/history','vcs/file-history','vcs/tree-history'].includes(endpoint)){
87
- if(payload.offset===null||payload.limit===null)throw invalid('Invalid history pagination.');
88
- if(payload.snapshot!==undefined&&(typeof payload.snapshot!=='string'||!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(payload.snapshot)))throw invalid('Invalid history snapshot.');
89
- if(!Number.isInteger(payload.offset??0)||(payload.offset??0)<0||(payload.offset??0)>10000||!Number.isInteger(payload.limit??50)||(payload.limit??50)<1||(payload.limit??50)>100)throw invalid('Invalid history pagination.');
90
- }
91
- if(['vcs/workspace-image','vcs/conflict-stages'].includes(endpoint)&&(typeof payload.id!=='string'||!/^[a-f0-9]{64}$/.test(payload.id)))throw invalid('Invalid workspace image change id.');
92
- if (!['all', 'unstaged', 'staged'].includes(payload.mode ?? 'all')) throw invalid('Unsupported comparison mode.');
93
- if (['vcs/compare','vcs/workspace-image'].includes(endpoint) && (typeof payload.id !== 'string' || !payload.id || payload.id.length > 32768)) throw invalid('A change id is required.');
94
- }
183
+ const allowedFields = REQUEST_FIELD_SETS.get(endpoint);
184
+ if (Object.keys(payload).some(key => !allowedFields.has(key))) throw invalid('Only Session-addressed repository requests are supported; unknown payload field.');
185
+ if (endpoint === 'vcs/repositories') validateRepositoriesPayload(payload);
186
+ else validateRepositoryPayload(endpoint, payload);
95
187
  }
96
188
 
189
+ /** Forward an optional field only when the request actually supplied it. */
190
+ const optionalField = (key, value) => value === undefined ? {} : {[key]: value};
191
+
97
192
  /** Dispatch only after grants and capacity have been checked by createHandler. */
98
193
  async function readRepository(api, endpoint, repository, payload, {cwd, mode, signal}) {
99
194
  switch (endpoint) {
100
195
  case 'vcs/tree-history':
101
- return api.listPathHistory({...repository},{commit:payload.commit,path:payload.path,offset:payload.offset??0,limit:payload.limit??50,follow:payload.follow??false, ...(payload.since !== undefined ? {since: payload.since} : {}), ...(payload.until !== undefined ? {until: payload.until} : {}), signal});
196
+ return api.listPathHistory({...repository}, {
197
+ commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, limit: payload.limit ?? 50, follow: payload.follow ?? false,
198
+ ...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
199
+ });
102
200
  case 'vcs/tree-segment':
103
201
  return api.getHistoricalSegment({...repository}, {commit: payload.commit, path: payload.path, offset: payload.offset ?? 0, signal});
104
202
  case 'vcs/workspace-image':
@@ -118,19 +216,21 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
118
216
  case 'vcs/tree':
119
217
  return api.getHistoricalTree({...repository}, {commit: payload.commit, signal});
120
218
  case 'vcs/blame':
121
- return api.getFileBlame({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
122
- ...(payload.startLine !== undefined ? {startLine: payload.startLine} : {}),
123
- ...(payload.lineLimit !== undefined ? {lineLimit: payload.lineLimit} : {}), signal});
219
+ return api.getFileBlame({...repository}, {
220
+ commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
221
+ ...optionalField('startLine', payload.startLine), ...optionalField('lineLimit', payload.lineLimit), signal,
222
+ });
124
223
  case 'vcs/file-history':
125
- return api.listFileHistory({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id,
126
- offset: payload.offset ?? 0, limit: payload.limit ?? 50,
127
- ...(payload.follow !== undefined ? {follow: payload.follow} : {}), ...(payload.since !== undefined ? {since: payload.since} : {}), ...(payload.until !== undefined ? {until: payload.until} : {}), signal});
224
+ return api.listFileHistory({...repository}, {
225
+ commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, offset: payload.offset ?? 0, limit: payload.limit ?? 50,
226
+ ...optionalField('follow', payload.follow), ...optionalField('since', payload.since), ...optionalField('until', payload.until), signal,
227
+ });
128
228
  case 'vcs/revision-changes':
129
229
  return api.getRevisionChanges({...repository}, {base: payload.base, target: payload.target, signal});
130
230
  case 'vcs/revision-compare':
131
- return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
231
+ return api.getRevisionComparison({...repository}, {base: payload.base, target: payload.target, id: payload.id, ...optionalField('large', payload.large), signal});
132
232
  case 'vcs/commit-compare':
133
- return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...(payload.large !== undefined ? {large: payload.large} : {}), signal});
233
+ return api.getCommitComparison({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, id: payload.id, ...optionalField('large', payload.large), signal});
134
234
  case 'vcs/commit':
135
235
  return api.getCommitDetails({...repository}, {commit: payload.commit, parentIndex: payload.parentIndex ?? 0, signal});
136
236
  case 'vcs/history':
@@ -138,9 +238,9 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
138
238
  case 'vcs/status':
139
239
  return {cwd, repository: {...repository}, changes: await api.listChanges({...repository}, mode, {signal}), mode};
140
240
  case 'vcs/conflict-stages':
141
- return api.getConflictStages({...repository},{mode,id:payload.id,signal});
241
+ return api.getConflictStages({...repository}, {mode, id: payload.id, signal});
142
242
  case 'vcs/compare':
143
- return api.getComparison({...repository}, {mode, id: payload.id, ...(signal !== undefined ? {signal} : {}), ...(payload.large !== undefined ? {large: payload.large} : {})});
243
+ return api.getComparison({...repository}, {mode, id: payload.id, ...optionalField('signal', signal), ...optionalField('large', payload.large)});
144
244
  }
145
245
  }
146
246
 
@@ -148,96 +248,170 @@ async function readRepository(api, endpoint, repository, payload, {cwd, mode, si
148
248
  export function createHandler(ctx, api = adapter, maxActive = 4, { now = Date.now, ttlMs = 5 * 60_000, maxSessions = 32, maxRepositories = 512, bindSvnIdentityResolver } = {}) {
149
249
  let active = 0, activeImages = 0, activeSegments = 0;
150
250
  const sessions = new Map();
251
+
252
+ /** Resolve a live Session grant, evicting expired entries and cwd-mismatched grants. */
151
253
  function current(sessionId, cwd) {
152
254
  for (const [id, entry] of sessions) if (entry.expiresAt <= now()) sessions.delete(id);
153
255
  const entry = sessions.get(sessionId);
154
256
  if (entry && entry.cwd !== cwd) { sessions.delete(sessionId); return undefined; }
155
257
  return entry;
156
258
  }
259
+
260
+ /** Re-read the Session cwd, then require the same grant object to still be current. */
157
261
  async function assertCurrent(sessionId, cwd, entry, signal) {
158
262
  const latest = await resolveSessionCwd(ctx, sessionId, signal);
159
263
  signal?.throwIfAborted();
160
264
  if (current(sessionId, latest) !== entry || latest !== cwd) throw rediscover();
161
265
  }
162
- if(bindSvnIdentityResolver!==undefined){if(typeof bindSvnIdentityResolver!=='function')throw new Error('Invalid internal SVN resolver binding');bindSvnIdentityResolver(async(address,signal)=>{validatePayload('vcs/svn-identity',address);signal?.throwIfAborted();const cwd=await resolveSessionCwd(ctx,address.sessionId,signal),entry=current(address.sessionId,cwd),repository=entry?.repositories.get(address.repositoryId);if(!repository)throw rediscover();if(repository.type!=='svn')throw invalid('SVN working copy required.');const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(address.sessionId,cwd,entry,signal);if(entry.repositories.get(address.repositoryId)!==repository)throw rediscover();return {...identity,cwd};});}
163
- return async (endpoint, payload, signal) => {
164
- if (['vcs/runtime-info','vcs/check-update'].includes(endpoint)) {
165
- try {
166
- signal?.throwIfAborted();
167
- if (!payload || typeof payload!=='object' || Array.isArray(payload) || Object.keys(payload).length) throw invalid('Update requests accept no fields.');
168
- if (endpoint==='vcs/runtime-info') return {ok:true,value:runtimeInfo()};
169
- if (active>=maxActive) return failure('vcs/busy','Too many VCS requests. Please retry.');
170
- active++;try { return {ok:true,value:await checkUpdate({signal})}; } finally { active--; }
171
- } catch(error) { return failure(signal?.aborted?'vcs/cancelled':'vcs/update-failed',error.message); }
266
+
267
+ /** Adapter work finished: both the cwd grant and the selected repository must still be current. */
268
+ async function assertCurrentGrant(sessionId, cwd, entry, repositoryId, repository, signal) {
269
+ await assertCurrent(sessionId, cwd, entry, signal);
270
+ if (entry.repositories.get(repositoryId) !== repository) throw rediscover();
271
+ }
272
+
273
+ if (bindSvnIdentityResolver !== undefined) {
274
+ if (typeof bindSvnIdentityResolver !== 'function') throw new Error('Invalid internal SVN resolver binding');
275
+ bindSvnIdentityResolver(async (address, signal) => {
276
+ validatePayload('vcs/svn-identity', address);
277
+ signal?.throwIfAborted();
278
+ const cwd = await resolveSessionCwd(ctx, address.sessionId, signal);
279
+ const entry = current(address.sessionId, cwd);
280
+ const repository = entry?.repositories.get(address.repositoryId);
281
+ if (!repository) throw rediscover();
282
+ if (repository.type !== 'svn') throw invalid('SVN working copy required.');
283
+ const identity = await api.getSvnIdentity({...repository}, {signal});
284
+ await assertCurrentGrant(address.sessionId, cwd, entry, address.repositoryId, repository, signal);
285
+ return {...identity, cwd};
286
+ });
287
+ }
288
+
289
+ /** Update queries accept no fields and are admitted separately from repository reads. */
290
+ async function handleUpdateRequest(endpoint, payload, signal) {
291
+ try {
292
+ signal?.throwIfAborted();
293
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length) throw invalid('Update requests accept no fields.');
294
+ if (endpoint === 'vcs/runtime-info') return {ok: true, value: runtimeInfo()};
295
+ if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
296
+ active++;
297
+ try { return {ok: true, value: await checkUpdate({signal})}; } finally { active--; }
298
+ } catch (error) {
299
+ return failure(signal?.aborted ? 'vcs/cancelled' : 'vcs/update-failed', error.message);
300
+ }
301
+ }
302
+
303
+ /** Discovery creates, replaces or merges grants; a stale scan can never resurrect one. */
304
+ async function discoverRepositories(payload, cwd, entry, signal) {
305
+ if (!entry) {
306
+ while (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
307
+ entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
308
+ sessions.set(payload.sessionId, entry);
309
+ }
310
+ const targeted = payload.subdirectory !== undefined;
311
+ const sequence = ++entry.scanSequence;
312
+ if (!targeted) entry.fullScanSequence = sequence;
313
+ const fullSequence = entry.fullScanSequence;
314
+ const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
315
+ // A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
316
+ await assertCurrent(payload.sessionId, cwd, entry, signal);
317
+ // Full scans replace grants. Never let an older full scan erase a newer scan,
318
+ // or an older targeted scan resurrect grants after a new full scan begins.
319
+ if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
320
+ const grants = targeted ? new Map(entry.repositories) : new Map();
321
+ let truncated = !!result.truncated;
322
+ const warnings = [...result.warnings];
323
+ const repositories = [];
324
+ for (const repository of result.repositories) {
325
+ if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
326
+ grants.set(repository.id, { ...repository });
327
+ repositories.push({ ...repository });
328
+ }
329
+ if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
330
+ entry.repositories = grants;
331
+ entry.expiresAt = now() + ttlMs;
332
+ return { cwd, repositories, truncated, warnings };
333
+ }
334
+
335
+ /** Read and revalidate SVN identity, returning only the fixed remote projection. */
336
+ async function readSvnIdentity(payload, cwd, entry, repository, signal) {
337
+ if (repository.type !== 'svn') throw invalid('SVN working copy required.');
338
+ const identity = await api.getSvnIdentity({...repository}, {signal});
339
+ await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
340
+ return {
341
+ root: identity.root, uuid: identity.uuid, scope: identity.scope,
342
+ origin: identity.origin, revision: identity.revision, remoteEnabled: false,
343
+ };
344
+ }
345
+
346
+ /** Reserve the independent image or text-segment slot; report a busy failure instead of queuing. */
347
+ function admitHeavyOperation(endpoint, payload) {
348
+ let imageSlot = false, segmentSlot = false;
349
+ if (endpoint === 'vcs/tree-segment' || (LARGE_COMPARISON_ENDPOINTS.has(endpoint) && payload.large === true)) {
350
+ if (activeSegments >= 1) return {busy: failure('vcs/busy', 'A text segment is already loading. Please retry.'), imageSlot, segmentSlot};
351
+ activeSegments++; segmentSlot = true;
352
+ }
353
+ if (IMAGE_ENDPOINTS.has(endpoint)) {
354
+ if (activeImages >= 2) return {busy: failure('vcs/busy', 'Too many image requests. Please retry.'), imageSlot, segmentSlot};
355
+ activeImages++; imageSlot = true;
172
356
  }
173
- if (!['vcs/svn-identity', 'vcs/repositories', 'vcs/status', 'vcs/compare', 'vcs/conflict-stages', 'vcs/history', 'vcs/commit', 'vcs/commit-compare', 'vcs/revision-changes', 'vcs/revision-compare', 'vcs/file-history', 'vcs/blame', 'vcs/tree', 'vcs/tree-file', 'vcs/tree-segment', 'vcs/tree-history', 'vcs/commit-image', 'vcs/revision-image', 'vcs/workspace-image', 'vcs/references', 'vcs/stash-list', 'vcs/grep'].includes(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
357
+ return {busy: null, imageSlot, segmentSlot};
358
+ }
359
+
360
+ /** Read through the adapter, then require both the cwd grant and the selected repository to be current. */
361
+ async function readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) {
362
+ const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
363
+ await assertCurrentGrant(payload.sessionId, cwd, entry, payload.repositoryId, repository, signal);
364
+ return value;
365
+ }
366
+
367
+ return async (endpoint, payload, signal) => {
368
+ if (UPDATE_ENDPOINTS.has(endpoint)) return handleUpdateRequest(endpoint, payload, signal);
369
+ if (!ACTION_ENDPOINTS.has(endpoint)) return failure('vcs/not-found', 'Unknown VCS endpoint.');
174
370
  if (active >= maxActive) return failure('vcs/busy', 'Too many VCS requests. Please retry.');
175
371
  active++;
176
- let imageSlot=false,segmentSlot=false;
372
+ let imageSlot = false, segmentSlot = false;
177
373
  try {
178
374
  signal?.throwIfAborted();
179
375
  validatePayload(endpoint, payload);
180
376
  const cwd = await resolveSessionCwd(ctx, payload.sessionId, signal);
181
377
  signal?.throwIfAborted();
182
- let entry = current(payload.sessionId, cwd);
183
- if (endpoint === 'vcs/repositories') {
184
- if (!entry) {
185
- while (sessions.size >= maxSessions) sessions.delete(sessions.keys().next().value);
186
- entry = { cwd, repositories: new Map(), expiresAt: now() + ttlMs, scanSequence: 0, fullScanSequence: 0 };
187
- sessions.set(payload.sessionId, entry);
188
- }
189
- const targeted = payload.subdirectory !== undefined;
190
- const sequence = ++entry.scanSequence;
191
- if (!targeted) entry.fullScanSequence = sequence;
192
- const fullSequence = entry.fullScanSequence;
193
- const result = await api.discoverRepositories(cwd, { signal, subdirectory: payload.subdirectory, maxDepth: 1, shallow: true });
194
- // A stale scan must never recreate/replace a grant invalidated by a cwd change or eviction.
195
- await assertCurrent(payload.sessionId, cwd, entry, signal);
196
- // Full scans replace grants. Never let an older full scan erase a newer scan,
197
- // or an older targeted scan resurrect grants after a new full scan begins.
198
- if ((!targeted && sequence !== entry.scanSequence) || (targeted && fullSequence !== entry.fullScanSequence)) throw rediscover();
199
- const grants = targeted ? new Map(entry.repositories) : new Map();
200
- let truncated = !!result.truncated;
201
- const warnings = [...result.warnings];
202
- const repositories = [];
203
- for (const repository of result.repositories) {
204
- if (!grants.has(repository.id) && grants.size >= maxRepositories) { truncated = true; continue; }
205
- grants.set(repository.id, { ...repository });
206
- repositories.push({ ...repository });
207
- }
208
- if (repositories.length !== result.repositories.length) warnings.push('Repository authorization limit reached; use a new Session or narrow its working directory.');
209
- entry.repositories = grants;
210
- entry.expiresAt = now() + ttlMs;
211
- return { ok: true, value: { cwd, repositories, truncated, warnings } };
212
- }
378
+ const entry = current(payload.sessionId, cwd);
379
+ if (endpoint === 'vcs/repositories') return { ok: true, value: await discoverRepositories(payload, cwd, entry, signal) };
213
380
  const repository = entry?.repositories.get(payload.repositoryId);
214
381
  if (!repository) throw rediscover();
215
- if(endpoint==='vcs/svn-identity'){
216
- if(repository.type!=='svn')throw invalid('SVN working copy required.');
217
- const identity=await api.getSvnIdentity({...repository},{signal});await assertCurrent(payload.sessionId,cwd,entry,signal);if(entry.repositories.get(payload.repositoryId)!==repository)throw rediscover();
218
- return {ok:true,value:{root:identity.root,uuid:identity.uuid,scope:identity.scope,origin:identity.origin,revision:identity.revision,remoteEnabled:false}};
219
- }
382
+ if (endpoint === 'vcs/svn-identity') return { ok: true, value: await readSvnIdentity(payload, cwd, entry, repository, signal) };
220
383
  const mode = payload.mode ?? 'all';
221
384
  if (repository.type === 'svn' && mode !== 'all') throw invalid('SVN supports only all mode.');
222
385
  // Both adapter operations revalidate the canonical root; comparison also validates
223
386
  // the change ID against fresh status, so do not duplicate a full status scan here.
224
- if(['vcs/history','vcs/commit','vcs/commit-compare','vcs/revision-changes','vcs/revision-compare','vcs/file-history','vcs/blame','vcs/tree','vcs/tree-file','vcs/tree-segment','vcs/tree-history','vcs/conflict-stages','vcs/commit-image','vcs/revision-image','vcs/workspace-image','vcs/references','vcs/stash-list','vcs/grep'].includes(endpoint)&&repository.type!=='git')throw invalid('History currently supports Git only.');
225
- if(endpoint==='vcs/tree-segment'||(['vcs/compare','vcs/commit-compare','vcs/revision-compare'].includes(endpoint)&&payload.large===true)){if(activeSegments>=1)return failure('vcs/busy','A text segment is already loading. Please retry.');activeSegments++;segmentSlot=true;}
226
- if(['vcs/commit-image','vcs/revision-image','vcs/workspace-image'].includes(endpoint)){
227
- if(activeImages>=2)return failure('vcs/busy','Too many image requests. Please retry.');
228
- activeImages++;imageSlot=true;
229
- }
230
- const value = await readRepository(api, endpoint, repository, payload, {cwd, mode, signal});
231
- await assertCurrent(payload.sessionId, cwd, entry, signal);
232
- if (entry.repositories.get(payload.repositoryId) !== repository) throw rediscover();
233
- return { ok: true, value };
387
+ if (GIT_ONLY_ENDPOINTS.has(endpoint) && repository.type !== 'git') throw invalid('History currently supports Git only.');
388
+ const admission = admitHeavyOperation(endpoint, payload);
389
+ imageSlot = admission.imageSlot;
390
+ segmentSlot = admission.segmentSlot;
391
+ if (admission.busy) return admission.busy;
392
+ return { ok: true, value: await readGrantedRepository(endpoint, repository, payload, cwd, mode, entry, signal) };
234
393
  } catch (error) {
235
394
  if (signal?.aborted || error?.name === 'AbortError') return failure('vcs/cancelled', 'The request was cancelled.');
236
- return failure(typeof error?.code === 'string' && error.code.startsWith('vcs/') ? error.code : 'vcs/operation-failed', error instanceof Error ? error.message : String(error));
237
- } finally { active--; if(imageSlot)activeImages--;if(segmentSlot)activeSegments--; }
395
+ const code = typeof error?.code === 'string' && error.code.startsWith('vcs/') ? error.code : 'vcs/operation-failed';
396
+ return failure(code, error instanceof Error ? error.message : String(error));
397
+ } finally {
398
+ active--;
399
+ if (imageSlot) activeImages--;
400
+ if (segmentSlot) activeSegments--;
401
+ }
238
402
  };
239
403
  }
240
404
 
405
+ /** Content type is fixed per build artifact; URL input never chooses it. */
406
+ const assetContentType = filename => filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8';
407
+ /** Shared bounded-response headers for both asset transports. */
408
+ const assetHeaders = (filename, bytes) => ({
409
+ 'Content-Type': assetContentType(filename),
410
+ 'Content-Length': String(bytes.byteLength),
411
+ 'Cache-Control': 'private, no-cache',
412
+ 'X-Content-Type-Options': 'nosniff',
413
+ });
414
+
241
415
  /** Three fixed authenticated assets; URL input never enters a filesystem path. */
242
416
  export function registerAssets(ctx, load = filename => readFile(new URL('./dist/' + filename, import.meta.url))) {
243
417
  for (const filename of ASSETS) {
@@ -248,12 +422,7 @@ export function registerAssets(ctx, load = filename => readFile(new URL('./dist/
248
422
  async fetch(request) {
249
423
  try {
250
424
  const bytes = await load(filename);
251
- return new Response(request.method === 'HEAD' ? null : bytes, { headers: {
252
- 'Content-Type': filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8',
253
- 'Content-Length': String(bytes.byteLength),
254
- 'Cache-Control': 'private, no-cache',
255
- 'X-Content-Type-Options': 'nosniff',
256
- } });
425
+ return new Response(request.method === 'HEAD' ? null : bytes, { headers: assetHeaders(filename, bytes) });
257
426
  } catch { return new Response('VCS editor assets unavailable. Build the plugin first.', { status: 503 }); }
258
427
  },
259
428
  });
@@ -270,7 +439,7 @@ export function registerDirectAssets(ctx, load = filename => readFile(new URL('.
270
439
  if (!['GET', 'HEAD'].includes(req.method)) { res.writeHead(405, {Allow:'GET, HEAD'}); res.end(); return; }
271
440
  try {
272
441
  const bytes = await load(filename);
273
- res.writeHead(200, {'Content-Type': filename.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8', 'Content-Length': String(bytes.byteLength), 'Cache-Control':'private, no-cache', 'X-Content-Type-Options':'nosniff'});
442
+ res.writeHead(200, assetHeaders(filename, bytes));
274
443
  res.end(req.method === 'HEAD' ? undefined : bytes);
275
444
  } catch { res.writeHead(503, {'Content-Type':'text/plain; charset=utf-8'}); res.end('VCS editor assets unavailable.'); }
276
445
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-vcs",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "private": false,
5
5
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
6
  "type": "module",