gitnexus 1.6.10-rc.16 → 1.6.10-rc.18
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/dist/core/ingestion/workers/parse-worker.js +1 -1
- package/dist/core/tree-sitter/safe-parse.d.ts +8 -3
- package/dist/core/tree-sitter/safe-parse.js +21 -7
- package/dist/server/analyze-launch.d.ts +6 -0
- package/dist/server/analyze-launch.js +82 -6
- package/dist/server/api.d.ts +30 -0
- package/dist/server/api.js +61 -19
- package/dist/storage/repo-manager.d.ts +12 -0
- package/dist/storage/repo-manager.js +12 -0
- package/package.json +1 -1
- package/scripts/cross-platform-tests.ts +5 -0
- package/web/assets/{index-B8sko6-6.js → index-B4eB4dNZ.js} +49 -49
- package/web/assets/{index-CzdH8ADg.css → index-CX_fADmQ.css} +1 -1
- package/web/index.html +2 -2
|
@@ -814,7 +814,7 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
|
|
|
814
814
|
try {
|
|
815
815
|
tree = parseSourceSafe(parser, parseContent, undefined, {
|
|
816
816
|
bufferSize: getTreeSitterBufferSize(parseContent),
|
|
817
|
-
});
|
|
817
|
+
}, file.path);
|
|
818
818
|
}
|
|
819
819
|
catch (err) {
|
|
820
820
|
reportWarning(`Failed to parse file ${file.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -42,7 +42,7 @@ export declare function getParseDiagnostics(tree: Parser.Tree): {
|
|
|
42
42
|
/**
|
|
43
43
|
* Parse `sourceText` safely on every platform.
|
|
44
44
|
*
|
|
45
|
-
* This is the single "parse safely" entry point and its contract covers
|
|
45
|
+
* This is the single "parse safely" entry point and its contract covers four
|
|
46
46
|
* concerns:
|
|
47
47
|
*
|
|
48
48
|
* 1. **Windows crash workaround.** Inputs longer than 32 767 chars are fed
|
|
@@ -62,7 +62,12 @@ export declare function getParseDiagnostics(tree: Parser.Tree): {
|
|
|
62
62
|
* the tree is **returned anyway** — error recovery is a downgrade, never a
|
|
63
63
|
* drop. Callers wanting the boolean use {@link parseHadErrors}.
|
|
64
64
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
65
|
+
* 4. **Embedded NUL recovery.** U+0000 is replaced with one ASCII space in
|
|
66
|
+
* the parser-only input. The one-for-one substitution keeps tree indices
|
|
67
|
+
* aligned with the original source while preventing language lexers from
|
|
68
|
+
* swallowing declarations during error recovery.
|
|
69
|
+
*
|
|
70
|
+
* @param label optional context (e.g. file path) attached to timeout errors,
|
|
71
|
+
* recovery warnings, and degraded-parse logs. Non-breaking trailing param.
|
|
67
72
|
*/
|
|
68
73
|
export declare function parseSourceSafe(parser: Parser, sourceText: string, oldTree?: Parser.Tree, options?: Parser.Options, label?: string): Parser.Tree;
|
|
@@ -155,7 +155,7 @@ export function getParseDiagnostics(tree) {
|
|
|
155
155
|
/**
|
|
156
156
|
* Parse `sourceText` safely on every platform.
|
|
157
157
|
*
|
|
158
|
-
* This is the single "parse safely" entry point and its contract covers
|
|
158
|
+
* This is the single "parse safely" entry point and its contract covers four
|
|
159
159
|
* concerns:
|
|
160
160
|
*
|
|
161
161
|
* 1. **Windows crash workaround.** Inputs longer than 32 767 chars are fed
|
|
@@ -175,22 +175,36 @@ export function getParseDiagnostics(tree) {
|
|
|
175
175
|
* the tree is **returned anyway** — error recovery is a downgrade, never a
|
|
176
176
|
* drop. Callers wanting the boolean use {@link parseHadErrors}.
|
|
177
177
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
178
|
+
* 4. **Embedded NUL recovery.** U+0000 is replaced with one ASCII space in
|
|
179
|
+
* the parser-only input. The one-for-one substitution keeps tree indices
|
|
180
|
+
* aligned with the original source while preventing language lexers from
|
|
181
|
+
* swallowing declarations during error recovery.
|
|
182
|
+
*
|
|
183
|
+
* @param label optional context (e.g. file path) attached to timeout errors,
|
|
184
|
+
* recovery warnings, and degraded-parse logs. Non-breaking trailing param.
|
|
180
185
|
*/
|
|
181
186
|
export function parseSourceSafe(parser, sourceText, oldTree, options, label) {
|
|
187
|
+
let parserInput = sourceText;
|
|
188
|
+
if (sourceText.includes('\0')) {
|
|
189
|
+
let nullByteCount = 0;
|
|
190
|
+
parserInput = sourceText.replaceAll('\0', () => {
|
|
191
|
+
nullByteCount += 1;
|
|
192
|
+
return ' ';
|
|
193
|
+
});
|
|
194
|
+
logger.warn({ ...(label ? { file: label } : {}), nullByteCount }, 'replaced embedded NUL bytes before tree-sitter parsing');
|
|
195
|
+
}
|
|
182
196
|
const budgetMs = resolveParseTimeoutMs();
|
|
183
197
|
const armed = armParseBudget(parser, budgetMs);
|
|
184
198
|
let tree;
|
|
185
199
|
try {
|
|
186
|
-
if (
|
|
187
|
-
tree = parser.parse(
|
|
200
|
+
if (parserInput.length <= DIRECT_PARSE_LIMIT_CHARS) {
|
|
201
|
+
tree = parser.parse(parserInput, oldTree, options);
|
|
188
202
|
}
|
|
189
203
|
else {
|
|
190
204
|
const input = (index) => {
|
|
191
|
-
if (index >=
|
|
205
|
+
if (index >= parserInput.length)
|
|
192
206
|
return null;
|
|
193
|
-
return
|
|
207
|
+
return parserInput.slice(index, index + SAFE_PARSE_CHUNK_CHARS);
|
|
194
208
|
};
|
|
195
209
|
tree = parser.parse(input, oldTree, options);
|
|
196
210
|
}
|
|
@@ -17,6 +17,12 @@ export interface LaunchDeps {
|
|
|
17
17
|
};
|
|
18
18
|
acquireRepoLock: (key: string) => string | null;
|
|
19
19
|
releaseRepoLock: (key: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Drops the server's cached LadybugDB handle (closeLbug). The worker
|
|
22
|
+
* process rewrites the repo's DB files on disk, so a connection opened
|
|
23
|
+
* before the rewrite keeps reading the pre-rewrite state until evicted.
|
|
24
|
+
*/
|
|
25
|
+
closeDbHandle: () => Promise<void>;
|
|
20
26
|
}
|
|
21
27
|
export interface LaunchOptions {
|
|
22
28
|
force?: boolean;
|
|
@@ -10,16 +10,83 @@
|
|
|
10
10
|
* path is resolved relative to `import.meta.url`.
|
|
11
11
|
*/
|
|
12
12
|
import path from 'path';
|
|
13
|
+
import { existsSync, statSync } from 'node:fs';
|
|
13
14
|
import { fork } from 'child_process';
|
|
14
15
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
15
16
|
import { createRequire } from 'node:module';
|
|
16
|
-
import { getStoragePath } from '../storage/repo-manager.js';
|
|
17
|
+
import { canonicalizePath, getStoragePath, INDEX_METADATA_FILE, listRegisteredRepos, registryPathEquals, } from '../storage/repo-manager.js';
|
|
17
18
|
import { logger } from '../core/logger.js';
|
|
18
19
|
const _require = createRequire(import.meta.url);
|
|
19
20
|
const MAX_WORKER_RETRIES = 2;
|
|
21
|
+
/**
|
|
22
|
+
* The worker reports `complete` over IPC before its on-disk finalization
|
|
23
|
+
* (LadybugDB checkpoint + native handle release + metadata write) is visible
|
|
24
|
+
* at `getStoragePath(targetPath)` — observed up to ~6.5s behind the IPC
|
|
25
|
+
* message. Opening the database inside that window is what the pre-IPC
|
|
26
|
+
* ordering was meant to prevent and is actively dangerous: reads fail with
|
|
27
|
+
* binder errors or return an empty graph, the open can quarantine the
|
|
28
|
+
* in-flight WAL, and the native layer racing the rewrite has crashed the
|
|
29
|
+
* whole server (SIGSEGV-class exit, no output) on slow CI runners.
|
|
30
|
+
*/
|
|
31
|
+
const FINALIZE_SETTLE_TIMEOUT_MS = 60_000;
|
|
32
|
+
const FINALIZE_SETTLE_POLL_MS = 200;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve once the analyzed repo's index is settled at `storagePath`: the
|
|
35
|
+
* LadybugDB file and metadata both exist AND were (re)written by THIS job
|
|
36
|
+
* (mtime >= jobStartMs — bare existence is not enough, a re-analysis leaves
|
|
37
|
+
* the previous index in place while it works), and no transient WAL/shadow/
|
|
38
|
+
* checkpoint sidecars remain (the worker's native close has finished).
|
|
39
|
+
*
|
|
40
|
+
* Never rejects. Timing out logs and proceeds (pre-gate behavior) rather
|
|
41
|
+
* than failing a job whose analysis genuinely succeeded — e.g. a no-op
|
|
42
|
+
* non-force analyze legitimately rewrites nothing.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Look up the analyzed repo's registered storage path. The request's
|
|
46
|
+
* user-provided path is used only as a comparison key; the filesystem probes
|
|
47
|
+
* below run against the registry's own `storagePath` — the server-owned
|
|
48
|
+
* record readers resolve through, and not a user-controlled value
|
|
49
|
+
* (CodeQL js/path-injection).
|
|
50
|
+
*/
|
|
51
|
+
const registeredStoragePath = async (targetPath) => {
|
|
52
|
+
const target = canonicalizePath(path.resolve(targetPath));
|
|
53
|
+
const entries = await listRegisteredRepos();
|
|
54
|
+
const entry = entries.find((e) => registryPathEquals(canonicalizePath(e.path), target));
|
|
55
|
+
return entry?.storagePath ?? null;
|
|
56
|
+
};
|
|
57
|
+
const waitForSettledIndex = async (targetPath, jobStartMs) => {
|
|
58
|
+
const settled = (storagePath) => {
|
|
59
|
+
try {
|
|
60
|
+
const lbugStat = statSync(path.join(storagePath, 'lbug'));
|
|
61
|
+
const metaStat = statSync(path.join(storagePath, INDEX_METADATA_FILE));
|
|
62
|
+
return (lbugStat.mtimeMs >= jobStartMs &&
|
|
63
|
+
metaStat.mtimeMs >= jobStartMs &&
|
|
64
|
+
['lbug.wal', 'lbug.shadow', 'lbug.wal.checkpoint'].every((f) => !existsSync(path.join(storagePath, f))));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false; // not written yet
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const deadline = Date.now() + FINALIZE_SETTLE_TIMEOUT_MS;
|
|
71
|
+
for (;;) {
|
|
72
|
+
// Re-resolved each round: the worker registers the repo as part of the
|
|
73
|
+
// finalization this gate is waiting out.
|
|
74
|
+
const storagePath = await registeredStoragePath(targetPath);
|
|
75
|
+
if (storagePath && settled(storagePath))
|
|
76
|
+
return;
|
|
77
|
+
if (Date.now() > deadline) {
|
|
78
|
+
logger.warn({ targetPath }, 'analyze finalization not visible after timeout; completing job anyway');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, FINALIZE_SETTLE_POLL_MS));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
20
84
|
export function createLaunchAnalysisWorker(deps) {
|
|
21
|
-
const { jobManager, backend, acquireRepoLock, releaseRepoLock } = deps;
|
|
85
|
+
const { jobManager, backend, acquireRepoLock, releaseRepoLock, closeDbHandle } = deps;
|
|
22
86
|
return function launchAnalysisWorker(job, targetPath, opts) {
|
|
87
|
+
// For waitForSettledIndex: files (re)written by this job have mtimes at or
|
|
88
|
+
// after this instant. Taken before the fork so no worker write predates it.
|
|
89
|
+
const jobStartMs = Date.now();
|
|
23
90
|
// Acquire shared repo lock (keyed on storagePath to match embed handler)
|
|
24
91
|
const analyzeLockKey = getStoragePath(targetPath);
|
|
25
92
|
const lockErr = acquireRepoLock(analyzeLockKey);
|
|
@@ -67,10 +134,17 @@ export function createLaunchAnalysisWorker(deps) {
|
|
|
67
134
|
}
|
|
68
135
|
else if (msg.type === 'complete') {
|
|
69
136
|
releaseRepoLock(analyzeLockKey);
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
137
|
+
// Before marking complete: (1) wait for the worker's on-disk
|
|
138
|
+
// finalization to settle (see waitForSettledIndex), (2) evict the
|
|
139
|
+
// cached DB handle — same invalidation DELETE /api/repo performs, a
|
|
140
|
+
// handle opened before the rewrite reads pre-rewrite state — and
|
|
141
|
+
// only then (3) reinitialize the backend. This makes the ordering
|
|
142
|
+
// comment below true in practice: the repo is actually queryable
|
|
143
|
+
// when the client receives the SSE complete event.
|
|
144
|
+
waitForSettledIndex(targetPath, jobStartMs)
|
|
145
|
+
.then(() => closeDbHandle())
|
|
146
|
+
.catch(() => { }) // best-effort: eviction failure must not fail the job
|
|
147
|
+
.then(() => backend.init())
|
|
74
148
|
.then(() => {
|
|
75
149
|
jobManager.updateJob(job.id, { status: 'complete', repoName: msg.result.repoName });
|
|
76
150
|
})
|
|
@@ -84,6 +158,8 @@ export function createLaunchAnalysisWorker(deps) {
|
|
|
84
158
|
}
|
|
85
159
|
else if (msg.type === 'error') {
|
|
86
160
|
releaseRepoLock(analyzeLockKey);
|
|
161
|
+
// A failed (force) analyze may still have rewritten DB files first.
|
|
162
|
+
void closeDbHandle().catch(() => { });
|
|
87
163
|
jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
|
|
88
164
|
}
|
|
89
165
|
});
|
package/dist/server/api.d.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* CORS is restricted to localhost, private/LAN networks, and the deployed site.
|
|
9
9
|
*/
|
|
10
10
|
import express from 'express';
|
|
11
|
+
import { type RegistryEntry } from '../storage/repo-manager.js';
|
|
11
12
|
import { type GraphNode, type GraphRelationship } from '../_shared/index.js';
|
|
13
|
+
import { JobManager } from './analyze-job.js';
|
|
12
14
|
/**
|
|
13
15
|
* Determine whether an HTTP Origin header value is allowed by CORS policy.
|
|
14
16
|
*
|
|
@@ -49,6 +51,34 @@ export declare const registerWebUI: (app: express.Express, staticDir: string | n
|
|
|
49
51
|
export declare const writeNdjsonRecord: (res: express.Response, record: GraphStreamRecord, signal?: AbortSignal) => Promise<void>;
|
|
50
52
|
export declare const getNodeQuery: (table: string, includeContent: boolean) => string;
|
|
51
53
|
export declare const streamGraphNdjson: (res: express.Response, includeContent?: boolean, signal?: AbortSignal) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Mount an SSE progress endpoint for a JobManager.
|
|
56
|
+
* Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
|
|
57
|
+
*
|
|
58
|
+
* Terminal payloads carry `repoPath` (the analyzed path) alongside the display
|
|
59
|
+
* `repoName` so clients can reconnect by path identity — with duplicate
|
|
60
|
+
* basenames, a name-only reconnect resolves to the first same-named sibling.
|
|
61
|
+
* Exported for unit tests that lock the wire payload shape.
|
|
62
|
+
*/
|
|
63
|
+
export declare const mountSSEProgress: (app: express.Express, routePath: string, jm: JobManager) => void;
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a `?repo=` request param against the registry in two tiers:
|
|
66
|
+
*
|
|
67
|
+
* 1. Path claim — any input containing a separator ('/' or '\\', which
|
|
68
|
+
* cover path.sep on every platform) is treated as a path claim and
|
|
69
|
+
* resolved by canonical registry path ONLY. A miss fails closed
|
|
70
|
+
* (null, never a basename fallback) so a stale or wrong path can
|
|
71
|
+
* never silently retarget a same-named sibling repo (#2419).
|
|
72
|
+
* Within this tier, only absolute or Windows-shaped ('\\') claims
|
|
73
|
+
* are worth canonicalizing; relative claims like 'org/name' or
|
|
74
|
+
* './repo' are rejected immediately WITHOUT touching the filesystem
|
|
75
|
+
* — canonicalizing them would run an attacker-influenced
|
|
76
|
+
* CWD-relative realpathSync probe on un-rate-limited GET routes,
|
|
77
|
+
* and no legitimate caller sends relative paths.
|
|
78
|
+
* 2. Name fallback — bare names (no separators) keep the legacy
|
|
79
|
+
* basename/name match for older callers.
|
|
80
|
+
*/
|
|
81
|
+
export declare const resolveRegisteredRepoEntry: (repos: RegistryEntry[], repoName?: string) => RegistryEntry | null;
|
|
52
82
|
/**
|
|
53
83
|
* Handle a GET /api/file request body. Extracted from createServer's route
|
|
54
84
|
* registration so it can be unit-tested without spinning up an HTTP server
|
package/dist/server/api.js
CHANGED
|
@@ -12,7 +12,7 @@ import cors from 'cors';
|
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import fs from 'fs/promises';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
|
-
import { loadMeta, listRegisteredRepos, getStoragePath } from '../storage/repo-manager.js';
|
|
15
|
+
import { canonicalizePath, cloneDirBelongsToEntry, loadMeta, listRegisteredRepos, getStoragePath, registryPathEquals, } from '../storage/repo-manager.js';
|
|
16
16
|
import { executeQuery, executePrepared, executeWithReusedStatement, streamQuery, flushWAL, closeLbug, withLbugDb, isReadOnlyDbError, } from '../core/lbug/lbug-adapter.js';
|
|
17
17
|
import { isValidQueryParams } from '../core/lbug/query-params.js';
|
|
18
18
|
import { NODE_TABLES } from '../_shared/index.js';
|
|
@@ -381,8 +381,13 @@ export const streamGraphNdjson = async (res, includeContent = false, signal) =>
|
|
|
381
381
|
/**
|
|
382
382
|
* Mount an SSE progress endpoint for a JobManager.
|
|
383
383
|
* Handles: initial state, terminal events, heartbeat, event IDs, client disconnect.
|
|
384
|
+
*
|
|
385
|
+
* Terminal payloads carry `repoPath` (the analyzed path) alongside the display
|
|
386
|
+
* `repoName` so clients can reconnect by path identity — with duplicate
|
|
387
|
+
* basenames, a name-only reconnect resolves to the first same-named sibling.
|
|
388
|
+
* Exported for unit tests that lock the wire payload shape.
|
|
384
389
|
*/
|
|
385
|
-
const mountSSEProgress = (app, routePath, jm) => {
|
|
390
|
+
export const mountSSEProgress = (app, routePath, jm) => {
|
|
386
391
|
app.get(routePath, (req, res) => {
|
|
387
392
|
let jobId;
|
|
388
393
|
try {
|
|
@@ -412,6 +417,7 @@ const mountSSEProgress = (app, routePath, jm) => {
|
|
|
412
417
|
eventId++;
|
|
413
418
|
res.write(`id: ${eventId}\nevent: ${job.status}\ndata: ${JSON.stringify({
|
|
414
419
|
repoName: job.repoName,
|
|
420
|
+
repoPath: job.repoPath,
|
|
415
421
|
error: job.error,
|
|
416
422
|
})}\n\n`);
|
|
417
423
|
res.end();
|
|
@@ -435,6 +441,7 @@ const mountSSEProgress = (app, routePath, jm) => {
|
|
|
435
441
|
const eventJob = jm.getJob(jobId);
|
|
436
442
|
res.write(`id: ${eventId}\nevent: ${progress.phase}\ndata: ${JSON.stringify({
|
|
437
443
|
repoName: eventJob?.repoName,
|
|
444
|
+
repoPath: eventJob?.repoPath,
|
|
438
445
|
error: eventJob?.error,
|
|
439
446
|
})}\n\n`);
|
|
440
447
|
clearInterval(heartbeat);
|
|
@@ -477,6 +484,43 @@ const requestedRepo = (req) => {
|
|
|
477
484
|
}
|
|
478
485
|
return undefined;
|
|
479
486
|
};
|
|
487
|
+
const repoParamBasename = (repoName) => repoName.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? repoName;
|
|
488
|
+
/**
|
|
489
|
+
* Resolve a `?repo=` request param against the registry in two tiers:
|
|
490
|
+
*
|
|
491
|
+
* 1. Path claim — any input containing a separator ('/' or '\\', which
|
|
492
|
+
* cover path.sep on every platform) is treated as a path claim and
|
|
493
|
+
* resolved by canonical registry path ONLY. A miss fails closed
|
|
494
|
+
* (null, never a basename fallback) so a stale or wrong path can
|
|
495
|
+
* never silently retarget a same-named sibling repo (#2419).
|
|
496
|
+
* Within this tier, only absolute or Windows-shaped ('\\') claims
|
|
497
|
+
* are worth canonicalizing; relative claims like 'org/name' or
|
|
498
|
+
* './repo' are rejected immediately WITHOUT touching the filesystem
|
|
499
|
+
* — canonicalizing them would run an attacker-influenced
|
|
500
|
+
* CWD-relative realpathSync probe on un-rate-limited GET routes,
|
|
501
|
+
* and no legitimate caller sends relative paths.
|
|
502
|
+
* 2. Name fallback — bare names (no separators) keep the legacy
|
|
503
|
+
* basename/name match for older callers.
|
|
504
|
+
*/
|
|
505
|
+
export const resolveRegisteredRepoEntry = (repos, repoName) => {
|
|
506
|
+
if (!repoName)
|
|
507
|
+
return repos[0] ?? null;
|
|
508
|
+
const looksLikePath = path.isAbsolute(repoName) || repoName.includes('/') || repoName.includes('\\');
|
|
509
|
+
if (looksLikePath) {
|
|
510
|
+
// Relative path claims fail closed with zero filesystem probes.
|
|
511
|
+
if (!path.isAbsolute(repoName) && !repoName.includes('\\'))
|
|
512
|
+
return null;
|
|
513
|
+
const requestedPath = canonicalizePath(repoName);
|
|
514
|
+
const pathMatch = repos.find((r) => registryPathEquals(canonicalizePath(r.path), requestedPath));
|
|
515
|
+
if (pathMatch)
|
|
516
|
+
return pathMatch;
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
const normalizedName = repoParamBasename(repoName);
|
|
520
|
+
return (repos.find((r) => r.name === normalizedName) ||
|
|
521
|
+
repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) ||
|
|
522
|
+
null);
|
|
523
|
+
};
|
|
480
524
|
/**
|
|
481
525
|
* Handle a GET /api/file request body. Extracted from createServer's route
|
|
482
526
|
* registration so it can be unit-tested without spinning up an HTTP server
|
|
@@ -703,6 +747,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
703
747
|
backend,
|
|
704
748
|
acquireRepoLock,
|
|
705
749
|
releaseRepoLock,
|
|
750
|
+
closeDbHandle: closeLbug,
|
|
706
751
|
});
|
|
707
752
|
/**
|
|
708
753
|
* Maximum time the hold-queue will wait for an active analysis job to complete.
|
|
@@ -713,19 +758,8 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
713
758
|
// Pass `req` to enable early exit if the client disconnects during the hold-queue wait.
|
|
714
759
|
const resolveRepo = async (repoName, isRetry = false, req) => {
|
|
715
760
|
const repos = await listRegisteredRepos();
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
// e.g. "C:\Users\LENOVO\.gitnexus\repos\todo.txt-cli" -> "todo.txt-cli"
|
|
719
|
-
const normalizedName = repoName ? path.basename(repoName) : undefined;
|
|
720
|
-
if (normalizedName) {
|
|
721
|
-
found =
|
|
722
|
-
repos.find((r) => r.name === normalizedName) ||
|
|
723
|
-
repos.find((r) => r.name.toLowerCase() === normalizedName.toLowerCase()) ||
|
|
724
|
-
null;
|
|
725
|
-
}
|
|
726
|
-
else if (repos.length > 0) {
|
|
727
|
-
found = repos[0]; // default to first repo
|
|
728
|
-
}
|
|
761
|
+
const found = resolveRegisteredRepoEntry(repos, repoName);
|
|
762
|
+
const normalizedName = repoName ? repoParamBasename(repoName) : undefined;
|
|
729
763
|
// If not yet in the registry, check whether a background job is actively cloning or
|
|
730
764
|
// analyzing this repo. Hold the connection open (up to 5 minutes) until it completes.
|
|
731
765
|
// We only wait for in-progress jobs ('queued'|'cloning'|'analyzing') — a 'complete' job
|
|
@@ -758,7 +792,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
758
792
|
if (currentJob.status === 'complete') {
|
|
759
793
|
await backend.init();
|
|
760
794
|
const freshRepos = await listRegisteredRepos();
|
|
761
|
-
return freshRepos
|
|
795
|
+
return resolveRegisteredRepoEntry(freshRepos, repoName);
|
|
762
796
|
}
|
|
763
797
|
await new Promise((r) => setTimeout(r, 1000));
|
|
764
798
|
}
|
|
@@ -775,7 +809,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
775
809
|
logger.debug({ repoName: String(normalizedName).replace(/[\r\n]/g, ' ') }, '[debug] resolveRepo 404, triggering deep init');
|
|
776
810
|
}
|
|
777
811
|
await backend.init();
|
|
778
|
-
return await resolveRepo(
|
|
812
|
+
return await resolveRepo(repoName, true, req);
|
|
779
813
|
}
|
|
780
814
|
return found;
|
|
781
815
|
};
|
|
@@ -827,6 +861,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
827
861
|
res.json(repos.map((r) => ({
|
|
828
862
|
name: r.name,
|
|
829
863
|
path: r.path,
|
|
864
|
+
repoPath: r.path,
|
|
830
865
|
indexedAt: r.indexedAt,
|
|
831
866
|
lastCommit: r.lastCommit,
|
|
832
867
|
stats: r.stats,
|
|
@@ -837,7 +872,11 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
837
872
|
}
|
|
838
873
|
});
|
|
839
874
|
// Get repo info
|
|
840
|
-
|
|
875
|
+
// Rate-limited (CodeQL js/missing-rate-limiting): resolveRepo canonicalizes
|
|
876
|
+
// the attacker-supplied ?repo= param (realpathSync probe for absolute /
|
|
877
|
+
// Windows-shaped claims). Default 60 rpm/IP — web callers hit this route
|
|
878
|
+
// only on connect/switch, never in a polling loop.
|
|
879
|
+
app.get('/api/repo', createRouteLimiter(), async (req, res) => {
|
|
841
880
|
try {
|
|
842
881
|
const entry = await resolveRepo(requestedRepo(req), false, req);
|
|
843
882
|
if (!entry) {
|
|
@@ -907,7 +946,10 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
907
946
|
catch {
|
|
908
947
|
/* repo name not eligible for a clone dir (local repo) */
|
|
909
948
|
}
|
|
910
|
-
|
|
949
|
+
// Only remove the clone dir when it is *this* entry's path — a local
|
|
950
|
+
// repo registered under the same name would otherwise take a cloned
|
|
951
|
+
// sibling's checkout down with it (see cloneDirBelongsToEntry).
|
|
952
|
+
if (cloneDir && cloneDirBelongsToEntry(cloneDir, entry.path)) {
|
|
911
953
|
try {
|
|
912
954
|
const stat = await fs.stat(cloneDir);
|
|
913
955
|
if (stat.isDirectory()) {
|
|
@@ -54,6 +54,18 @@ export declare const canonicalizePath: (p: string) => string;
|
|
|
54
54
|
* lookups/dedup/finalize checks all share so they answer identically.
|
|
55
55
|
*/
|
|
56
56
|
export declare const registryPathEquals: (a: string, b: string) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Does the clone dir derived from an entry's *name* actually belong to that
|
|
59
|
+
* entry? Registry names are not unique across storage locations: a cloned
|
|
60
|
+
* repo under `~/.gitnexus/repos/<name>` and a local repo registered under the
|
|
61
|
+
* same name share a `getCloneDir(entry.name)` result. The server's delete
|
|
62
|
+
* handler must therefore never remove the clone dir based on the name alone —
|
|
63
|
+
* only when the entry's own `path` resolves to that dir (mirroring its step-2b
|
|
64
|
+
* rule that cleanup is driven off `entry.path`, so a same-named sibling's
|
|
65
|
+
* clone is never removed). Both sides are canonicalised so symlinked or
|
|
66
|
+
* differently-spelled forms of the same dir still match.
|
|
67
|
+
*/
|
|
68
|
+
export declare const cloneDirBelongsToEntry: (cloneDir: string, entryPath: string) => boolean;
|
|
57
69
|
export interface RepoMeta {
|
|
58
70
|
repoPath: string;
|
|
59
71
|
lastCommit: string;
|
|
@@ -71,6 +71,18 @@ export const canonicalizePath = (p) => {
|
|
|
71
71
|
* lookups/dedup/finalize checks all share so they answer identically.
|
|
72
72
|
*/
|
|
73
73
|
export const registryPathEquals = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
74
|
+
/**
|
|
75
|
+
* Does the clone dir derived from an entry's *name* actually belong to that
|
|
76
|
+
* entry? Registry names are not unique across storage locations: a cloned
|
|
77
|
+
* repo under `~/.gitnexus/repos/<name>` and a local repo registered under the
|
|
78
|
+
* same name share a `getCloneDir(entry.name)` result. The server's delete
|
|
79
|
+
* handler must therefore never remove the clone dir based on the name alone —
|
|
80
|
+
* only when the entry's own `path` resolves to that dir (mirroring its step-2b
|
|
81
|
+
* rule that cleanup is driven off `entry.path`, so a same-named sibling's
|
|
82
|
+
* clone is never removed). Both sides are canonicalised so symlinked or
|
|
83
|
+
* differently-spelled forms of the same dir still match.
|
|
84
|
+
*/
|
|
85
|
+
export const cloneDirBelongsToEntry = (cloneDir, entryPath) => registryPathEquals(canonicalizePath(cloneDir), canonicalizePath(entryPath));
|
|
74
86
|
/**
|
|
75
87
|
* Bumped whenever incremental-indexing invariants change incompatibly.
|
|
76
88
|
* v2: `BasicBlock.callees` column added (statement-precise inter-procedural
|
package/package.json
CHANGED
|
@@ -74,6 +74,11 @@ const PLATFORM_LOGIC = [
|
|
|
74
74
|
// (macos), so the header parsing is proven on genuine binaries, not synthetic
|
|
75
75
|
// buffers (the ubuntu suite covers the ELF path).
|
|
76
76
|
'test/integration/extension-binary-real.test.ts',
|
|
77
|
+
// Server repo resolver branches on path shape (path.isAbsolute, backslash
|
|
78
|
+
// detection) and canonicalizePath/realpathSync, all of which differ between
|
|
79
|
+
// POSIX and Windows — the fail-closed path-claim semantics must hold on the
|
|
80
|
+
// real windows-latest path implementation (#2419/#2420).
|
|
81
|
+
'test/unit/server-api-repo-resolution.test.ts',
|
|
77
82
|
];
|
|
78
83
|
|
|
79
84
|
// Native LadybugDB integration tests — exercise the @ladybugdb/core
|