@git-stunts/git-warp 10.4.2 → 10.8.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/SECURITY.md +89 -1
- package/bin/presenters/index.js +208 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +407 -0
- package/bin/warp-graph.js +206 -534
- package/index.d.ts +24 -0
- package/package.json +2 -2
- package/src/domain/WarpGraph.js +72 -15
- package/src/domain/services/HttpSyncServer.js +74 -6
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +9 -56
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/visualization/renderers/ascii/seek.js +172 -22
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC-SHA256 request signing and verification for the sync protocol.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Canonical payload construction
|
|
6
|
+
* - Request signing (client side)
|
|
7
|
+
* - Request verification with replay protection (server side)
|
|
8
|
+
*
|
|
9
|
+
* @module domain/services/SyncAuthService
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import LRUCache from '../utils/LRUCache.js';
|
|
13
|
+
import defaultCrypto from '../utils/defaultCrypto.js';
|
|
14
|
+
import nullLogger from '../utils/nullLogger.js';
|
|
15
|
+
|
|
16
|
+
const SIG_VERSION = '1';
|
|
17
|
+
const SIG_PREFIX = 'warp-v1';
|
|
18
|
+
const HMAC_ALGO = 'sha256';
|
|
19
|
+
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
|
20
|
+
const DEFAULT_NONCE_CAPACITY = 100_000;
|
|
21
|
+
const NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
22
|
+
const SIG_HEX_LENGTH = 64;
|
|
23
|
+
const HEX_PATTERN = /^[0-9a-f]+$/;
|
|
24
|
+
const MAX_TIMESTAMP_DIGITS = 16;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonicalizes a URL path for signature computation.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} url - URL or path to canonicalize
|
|
30
|
+
* @returns {string} Canonical path (pathname + search, no fragment)
|
|
31
|
+
*/
|
|
32
|
+
export function canonicalizePath(url) {
|
|
33
|
+
const parsed = new URL(url, 'http://localhost');
|
|
34
|
+
return parsed.pathname + (parsed.search || '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the canonical string that gets signed.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} params
|
|
41
|
+
* @param {string} params.keyId - Key identifier
|
|
42
|
+
* @param {string} params.method - HTTP method (uppercased by caller)
|
|
43
|
+
* @param {string} params.path - Canonical path
|
|
44
|
+
* @param {string} params.timestamp - Epoch milliseconds as string
|
|
45
|
+
* @param {string} params.nonce - UUIDv4 nonce
|
|
46
|
+
* @param {string} params.contentType - Content-Type header value
|
|
47
|
+
* @param {string} params.bodySha256 - Hex SHA-256 of request body
|
|
48
|
+
* @returns {string} Pipe-delimited canonical payload
|
|
49
|
+
*/
|
|
50
|
+
export function buildCanonicalPayload({ keyId, method, path, timestamp, nonce, contentType, bodySha256 }) {
|
|
51
|
+
return `${SIG_PREFIX}|${keyId}|${method}|${path}|${timestamp}|${nonce}|${contentType}|${bodySha256}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Signs an outgoing sync request.
|
|
56
|
+
*
|
|
57
|
+
* @param {Object} params
|
|
58
|
+
* @param {string} params.method - HTTP method
|
|
59
|
+
* @param {string} params.path - Canonical path
|
|
60
|
+
* @param {string} params.contentType - Content-Type header value
|
|
61
|
+
* @param {Buffer|Uint8Array} params.body - Raw request body
|
|
62
|
+
* @param {string} params.secret - Shared secret
|
|
63
|
+
* @param {string} params.keyId - Key identifier
|
|
64
|
+
* @param {Object} deps
|
|
65
|
+
* @param {import('../../ports/CryptoPort.js').default} [deps.crypto] - Crypto port
|
|
66
|
+
* @returns {Promise<Record<string, string>>} Auth headers
|
|
67
|
+
*/
|
|
68
|
+
export async function signSyncRequest({ method, path, contentType, body, secret, keyId }, { crypto } = {}) {
|
|
69
|
+
const c = crypto || defaultCrypto;
|
|
70
|
+
const timestamp = String(Date.now());
|
|
71
|
+
const nonce = globalThis.crypto.randomUUID();
|
|
72
|
+
|
|
73
|
+
const bodySha256 = await c.hash('sha256', body);
|
|
74
|
+
const canonical = buildCanonicalPayload({
|
|
75
|
+
keyId,
|
|
76
|
+
method: method.toUpperCase(),
|
|
77
|
+
path,
|
|
78
|
+
timestamp,
|
|
79
|
+
nonce,
|
|
80
|
+
contentType,
|
|
81
|
+
bodySha256,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const hmacBuf = await c.hmac(HMAC_ALGO, secret, canonical);
|
|
85
|
+
const signature = Buffer.from(hmacBuf).toString('hex');
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
'x-warp-sig-version': SIG_VERSION,
|
|
89
|
+
'x-warp-key-id': keyId,
|
|
90
|
+
'x-warp-timestamp': timestamp,
|
|
91
|
+
'x-warp-nonce': nonce,
|
|
92
|
+
'x-warp-signature': signature,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} reason
|
|
98
|
+
* @param {number} status
|
|
99
|
+
* @returns {{ ok: false, reason: string, status: number }}
|
|
100
|
+
*/
|
|
101
|
+
function fail(reason, status) {
|
|
102
|
+
return { ok: false, reason, status };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
107
|
+
*/
|
|
108
|
+
function _freshMetrics() {
|
|
109
|
+
return {
|
|
110
|
+
authFailCount: 0,
|
|
111
|
+
replayRejectCount: 0,
|
|
112
|
+
nonceEvictions: 0,
|
|
113
|
+
clockSkewRejects: 0,
|
|
114
|
+
malformedRejects: 0,
|
|
115
|
+
logOnlyPassthroughs: 0,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validates format of individual auth header values.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} timestamp
|
|
123
|
+
* @param {string} nonce
|
|
124
|
+
* @param {string} signature
|
|
125
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true }}
|
|
126
|
+
*/
|
|
127
|
+
function _checkHeaderFormats(timestamp, nonce, signature) {
|
|
128
|
+
if (!/^\d+$/.test(timestamp) || timestamp.length > MAX_TIMESTAMP_DIGITS) {
|
|
129
|
+
return fail('MALFORMED_TIMESTAMP', 400);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!NONCE_PATTERN.test(nonce)) {
|
|
133
|
+
return fail('MALFORMED_NONCE', 400);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (signature.length !== SIG_HEX_LENGTH || !HEX_PATTERN.test(signature)) {
|
|
137
|
+
return fail('MALFORMED_SIGNATURE', 400);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {Record<string, string>|undefined} keys
|
|
145
|
+
*/
|
|
146
|
+
function _validateKeys(keys) {
|
|
147
|
+
if (!keys || typeof keys !== 'object' || Object.keys(keys).length === 0) {
|
|
148
|
+
throw new Error('SyncAuthService requires a non-empty keys map');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default class SyncAuthService {
|
|
153
|
+
/**
|
|
154
|
+
* @param {Object} options
|
|
155
|
+
* @param {Record<string, string>} options.keys - Key-id to secret mapping
|
|
156
|
+
* @param {'enforce'|'log-only'} [options.mode='enforce'] - Auth enforcement mode
|
|
157
|
+
* @param {number} [options.nonceCapacity] - Nonce LRU capacity
|
|
158
|
+
* @param {number} [options.maxClockSkewMs] - Max clock skew tolerance
|
|
159
|
+
* @param {import('../../ports/CryptoPort.js').default} [options.crypto] - Crypto port
|
|
160
|
+
* @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger port
|
|
161
|
+
* @param {() => number} [options.wallClockMs] - Wall clock function
|
|
162
|
+
*/
|
|
163
|
+
constructor({ keys, mode = 'enforce', nonceCapacity, maxClockSkewMs, crypto, logger, wallClockMs } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type
|
|
164
|
+
_validateKeys(keys);
|
|
165
|
+
this._keys = keys;
|
|
166
|
+
this._mode = mode;
|
|
167
|
+
this._crypto = crypto || defaultCrypto;
|
|
168
|
+
this._logger = logger || nullLogger;
|
|
169
|
+
this._wallClockMs = wallClockMs || (() => Date.now());
|
|
170
|
+
this._maxClockSkewMs = typeof maxClockSkewMs === 'number' ? maxClockSkewMs : MAX_CLOCK_SKEW_MS;
|
|
171
|
+
this._nonceCache = new LRUCache(nonceCapacity || DEFAULT_NONCE_CAPACITY);
|
|
172
|
+
this._metrics = _freshMetrics();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @returns {'enforce'|'log-only'} */
|
|
176
|
+
get mode() {
|
|
177
|
+
return this._mode;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validates auth header presence and format.
|
|
182
|
+
*
|
|
183
|
+
* @param {Record<string, string>} headers
|
|
184
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true, sigVersion: string, signature: string, timestamp: string, nonce: string, keyId: string }}
|
|
185
|
+
* @private
|
|
186
|
+
*/
|
|
187
|
+
_validateHeaders(headers) {
|
|
188
|
+
const sigVersion = headers['x-warp-sig-version'];
|
|
189
|
+
if (sigVersion !== SIG_VERSION) {
|
|
190
|
+
return fail('INVALID_VERSION', 400);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const keyId = headers['x-warp-key-id'];
|
|
194
|
+
const signature = headers['x-warp-signature'];
|
|
195
|
+
const timestamp = headers['x-warp-timestamp'];
|
|
196
|
+
const nonce = headers['x-warp-nonce'];
|
|
197
|
+
|
|
198
|
+
if (!keyId || !signature || !timestamp || !nonce) {
|
|
199
|
+
return fail('MISSING_AUTH', 401);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const formatCheck = _checkHeaderFormats(timestamp, nonce, signature);
|
|
203
|
+
if (!formatCheck.ok) {
|
|
204
|
+
return formatCheck;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { ok: true, sigVersion, signature, timestamp, nonce, keyId };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Checks that the timestamp is within the allowed clock skew.
|
|
212
|
+
*
|
|
213
|
+
* @param {string} timestamp - Epoch ms as string
|
|
214
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true }}
|
|
215
|
+
* @private
|
|
216
|
+
*/
|
|
217
|
+
_validateFreshness(timestamp) {
|
|
218
|
+
const ts = Number(timestamp);
|
|
219
|
+
const now = this._wallClockMs();
|
|
220
|
+
if (Math.abs(now - ts) > this._maxClockSkewMs) {
|
|
221
|
+
this._metrics.clockSkewRejects += 1;
|
|
222
|
+
return fail('EXPIRED', 403);
|
|
223
|
+
}
|
|
224
|
+
return { ok: true };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Atomically reserves a nonce. Returns replay failure if already seen.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} nonce
|
|
231
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true }}
|
|
232
|
+
* @private
|
|
233
|
+
*/
|
|
234
|
+
_reserveNonce(nonce) {
|
|
235
|
+
if (this._nonceCache.has(nonce)) {
|
|
236
|
+
this._metrics.replayRejectCount += 1;
|
|
237
|
+
return fail('REPLAY', 403);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const sizeBefore = this._nonceCache.size;
|
|
241
|
+
this._nonceCache.set(nonce, true);
|
|
242
|
+
if (this._nonceCache.size <= sizeBefore && sizeBefore >= this._nonceCache.maxSize) {
|
|
243
|
+
this._metrics.nonceEvictions += 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { ok: true };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolves the shared secret for a key-id.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} keyId
|
|
253
|
+
* @returns {{ ok: false, reason: string, status: number } | { ok: true, secret: string }}
|
|
254
|
+
* @private
|
|
255
|
+
*/
|
|
256
|
+
_resolveKey(keyId) {
|
|
257
|
+
const secret = this._keys[keyId];
|
|
258
|
+
if (!secret) {
|
|
259
|
+
return fail('UNKNOWN_KEY_ID', 401);
|
|
260
|
+
}
|
|
261
|
+
return { ok: true, secret };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Verifies the HMAC signature against the canonical payload.
|
|
266
|
+
*
|
|
267
|
+
* @param {Object} params
|
|
268
|
+
* @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} params.request
|
|
269
|
+
* @param {string} params.secret
|
|
270
|
+
* @param {string} params.keyId
|
|
271
|
+
* @param {string} params.timestamp
|
|
272
|
+
* @param {string} params.nonce
|
|
273
|
+
* @returns {Promise<{ ok: false, reason: string, status: number } | { ok: true }>}
|
|
274
|
+
* @private
|
|
275
|
+
*/
|
|
276
|
+
async _verifySignature({ request, secret, keyId, timestamp, nonce }) {
|
|
277
|
+
const body = request.body || new Uint8Array(0);
|
|
278
|
+
const bodySha256 = await this._crypto.hash('sha256', body);
|
|
279
|
+
const contentType = request.headers['content-type'] || '';
|
|
280
|
+
const path = canonicalizePath(request.url || '/');
|
|
281
|
+
|
|
282
|
+
const canonical = buildCanonicalPayload({
|
|
283
|
+
keyId,
|
|
284
|
+
method: (request.method || 'POST').toUpperCase(),
|
|
285
|
+
path,
|
|
286
|
+
timestamp,
|
|
287
|
+
nonce,
|
|
288
|
+
contentType,
|
|
289
|
+
bodySha256,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const expectedBuf = await this._crypto.hmac(HMAC_ALGO, secret, canonical);
|
|
293
|
+
const receivedHex = request.headers['x-warp-signature'];
|
|
294
|
+
|
|
295
|
+
let receivedBuf;
|
|
296
|
+
try {
|
|
297
|
+
receivedBuf = Buffer.from(receivedHex, 'hex');
|
|
298
|
+
} catch {
|
|
299
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (receivedBuf.length !== expectedBuf.length) {
|
|
303
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let equal;
|
|
307
|
+
try {
|
|
308
|
+
equal = this._crypto.timingSafeEqual(
|
|
309
|
+
Buffer.from(expectedBuf),
|
|
310
|
+
receivedBuf,
|
|
311
|
+
);
|
|
312
|
+
} catch {
|
|
313
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!equal) {
|
|
317
|
+
return fail('INVALID_SIGNATURE', 401);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { ok: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Verifies an incoming sync request.
|
|
325
|
+
*
|
|
326
|
+
* @param {{ method: string, url: string, headers: Record<string, string>, body?: Buffer|Uint8Array }} request
|
|
327
|
+
* @returns {Promise<{ ok: true } | { ok: false, reason: string, status: number }>}
|
|
328
|
+
*/
|
|
329
|
+
async verify(request) {
|
|
330
|
+
const headers = request.headers || {};
|
|
331
|
+
|
|
332
|
+
const headerResult = this._validateHeaders(headers);
|
|
333
|
+
if (!headerResult.ok) {
|
|
334
|
+
this._metrics.malformedRejects += 1;
|
|
335
|
+
return this._fail('header validation failed', { reason: headerResult.reason }, headerResult);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const { timestamp, nonce, keyId } = headerResult;
|
|
339
|
+
|
|
340
|
+
const freshnessResult = this._validateFreshness(timestamp);
|
|
341
|
+
if (!freshnessResult.ok) {
|
|
342
|
+
return this._fail('clock skew rejected', { keyId, timestamp }, freshnessResult);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const keyResult = this._resolveKey(keyId);
|
|
346
|
+
if (!keyResult.ok) {
|
|
347
|
+
return this._fail('unknown key-id', { keyId }, keyResult);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const sigResult = await this._verifySignature({
|
|
351
|
+
request, secret: keyResult.secret, keyId, timestamp, nonce,
|
|
352
|
+
});
|
|
353
|
+
if (!sigResult.ok) {
|
|
354
|
+
return this._fail('signature mismatch', { keyId }, sigResult);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Reserve nonce only after signature verification succeeds to avoid
|
|
358
|
+
// consuming nonces for requests with invalid signatures.
|
|
359
|
+
const nonceResult = this._reserveNonce(nonce);
|
|
360
|
+
if (!nonceResult.ok) {
|
|
361
|
+
return this._fail('replay detected', { keyId, nonce }, nonceResult);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return { ok: true };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Records an auth failure and returns the result.
|
|
369
|
+
* @param {string} message
|
|
370
|
+
* @param {Record<string, *>} context
|
|
371
|
+
* @param {{ ok: false, reason: string, status: number }} result
|
|
372
|
+
* @returns {{ ok: false, reason: string, status: number }}
|
|
373
|
+
* @private
|
|
374
|
+
*/
|
|
375
|
+
_fail(message, context, result) {
|
|
376
|
+
this._metrics.authFailCount += 1;
|
|
377
|
+
this._logger.warn(`sync auth: ${message}`, context);
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Increments the log-only passthrough counter.
|
|
383
|
+
*/
|
|
384
|
+
recordLogOnlyPassthrough() {
|
|
385
|
+
this._metrics.logOnlyPassthroughs += 1;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Returns a snapshot of auth metrics.
|
|
390
|
+
*
|
|
391
|
+
* @returns {{ authFailCount: number, replayRejectCount: number, nonceEvictions: number, clockSkewRejects: number, malformedRejects: number, logOnlyPassthroughs: number }}
|
|
392
|
+
*/
|
|
393
|
+
getMetrics() {
|
|
394
|
+
return { ...this._metrics };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
|
|
46
46
|
import { retry } from '@git-stunts/alfred';
|
|
47
47
|
import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
|
|
48
|
+
import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.js';
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Transient Git errors that are safe to retry automatically.
|
|
@@ -374,30 +375,13 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
374
375
|
|
|
375
376
|
/**
|
|
376
377
|
* Validates that a ref is safe to use in git commands.
|
|
377
|
-
*
|
|
378
|
+
* Delegates to shared validation in adapterValidation.js.
|
|
378
379
|
* @param {string} ref - The ref to validate
|
|
379
380
|
* @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
|
|
380
381
|
* @private
|
|
381
382
|
*/
|
|
382
383
|
_validateRef(ref) {
|
|
383
|
-
|
|
384
|
-
throw new Error('Ref must be a non-empty string');
|
|
385
|
-
}
|
|
386
|
-
// Prevent buffer overflow attacks with extremely long refs
|
|
387
|
-
if (ref.length > 1024) {
|
|
388
|
-
throw new Error(`Ref too long: ${ref.length} chars. Maximum is 1024`);
|
|
389
|
-
}
|
|
390
|
-
// Prevent git option injection (must check before pattern matching)
|
|
391
|
-
if (ref.startsWith('-') || ref.startsWith('--')) {
|
|
392
|
-
throw new Error(`Invalid ref: ${ref}. Refs cannot start with - or --. See https://github.com/git-stunts/git-warp#security`);
|
|
393
|
-
}
|
|
394
|
-
// Allow alphanumeric, ., /, -, _ in names
|
|
395
|
-
// Allow ancestry operators: ^ or ~ optionally followed by digits
|
|
396
|
-
// Allow range operators: .. between names
|
|
397
|
-
const validRefPattern = /^[a-zA-Z0-9._/-]+((~\d*|\^\d*|\.\.[a-zA-Z0-9._/-]+)*)$/;
|
|
398
|
-
if (!validRefPattern.test(ref)) {
|
|
399
|
-
throw new Error(`Invalid ref format: ${ref}. Only alphanumeric characters, ., /, -, _, ^, ~, and range operators are allowed. See https://github.com/git-stunts/git-warp#ref-validation`);
|
|
400
|
-
}
|
|
384
|
+
validateRef(ref);
|
|
401
385
|
}
|
|
402
386
|
|
|
403
387
|
/**
|
|
@@ -546,42 +530,24 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
546
530
|
|
|
547
531
|
/**
|
|
548
532
|
* Validates that an OID is safe to use in git commands.
|
|
533
|
+
* Delegates to shared validation in adapterValidation.js.
|
|
549
534
|
* @param {string} oid - The OID to validate
|
|
550
535
|
* @throws {Error} If OID is invalid
|
|
551
536
|
* @private
|
|
552
537
|
*/
|
|
553
538
|
_validateOid(oid) {
|
|
554
|
-
|
|
555
|
-
throw new Error('OID must be a non-empty string');
|
|
556
|
-
}
|
|
557
|
-
if (oid.length > 64) {
|
|
558
|
-
throw new Error(`OID too long: ${oid.length} chars. Maximum is 64`);
|
|
559
|
-
}
|
|
560
|
-
const validOidPattern = /^[0-9a-fA-F]{4,64}$/;
|
|
561
|
-
if (!validOidPattern.test(oid)) {
|
|
562
|
-
throw new Error(`Invalid OID format: ${oid}`);
|
|
563
|
-
}
|
|
539
|
+
validateOid(oid);
|
|
564
540
|
}
|
|
565
541
|
|
|
566
542
|
/**
|
|
567
543
|
* Validates that a limit is a safe positive integer.
|
|
544
|
+
* Delegates to shared validation in adapterValidation.js.
|
|
568
545
|
* @param {number} limit - The limit to validate
|
|
569
546
|
* @throws {Error} If limit is invalid
|
|
570
547
|
* @private
|
|
571
548
|
*/
|
|
572
549
|
_validateLimit(limit) {
|
|
573
|
-
|
|
574
|
-
throw new Error('Limit must be a finite number');
|
|
575
|
-
}
|
|
576
|
-
if (!Number.isInteger(limit)) {
|
|
577
|
-
throw new Error('Limit must be an integer');
|
|
578
|
-
}
|
|
579
|
-
if (limit <= 0) {
|
|
580
|
-
throw new Error('Limit must be a positive integer');
|
|
581
|
-
}
|
|
582
|
-
if (limit > 10_000_000) {
|
|
583
|
-
throw new Error(`Limit too large: ${limit}. Maximum is 10,000,000`);
|
|
584
|
-
}
|
|
550
|
+
validateLimit(limit);
|
|
585
551
|
}
|
|
586
552
|
|
|
587
553
|
/**
|
|
@@ -721,26 +687,13 @@ export default class GitGraphAdapter extends GraphPersistencePort {
|
|
|
721
687
|
|
|
722
688
|
/**
|
|
723
689
|
* Validates that a config key is safe to use in git commands.
|
|
690
|
+
* Delegates to shared validation in adapterValidation.js.
|
|
724
691
|
* @param {string} key - The config key to validate
|
|
725
692
|
* @throws {Error} If key is invalid
|
|
726
693
|
* @private
|
|
727
694
|
*/
|
|
728
695
|
_validateConfigKey(key) {
|
|
729
|
-
|
|
730
|
-
throw new Error('Config key must be a non-empty string');
|
|
731
|
-
}
|
|
732
|
-
if (key.length > 256) {
|
|
733
|
-
throw new Error(`Config key too long: ${key.length} chars. Maximum is 256`);
|
|
734
|
-
}
|
|
735
|
-
// Prevent git option injection
|
|
736
|
-
if (key.startsWith('-')) {
|
|
737
|
-
throw new Error(`Invalid config key: ${key}. Keys cannot start with -`);
|
|
738
|
-
}
|
|
739
|
-
// Allow section.subsection.key format
|
|
740
|
-
const validKeyPattern = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
|
|
741
|
-
if (!validKeyPattern.test(key)) {
|
|
742
|
-
throw new Error(`Invalid config key format: ${key}`);
|
|
743
|
-
}
|
|
696
|
+
validateConfigKey(key);
|
|
744
697
|
}
|
|
745
698
|
|
|
746
699
|
/**
|