gitnexus 1.6.10-rc.17 → 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.
@@ -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
- // Reinitialize backend BEFORE marking complete ensures the new repo
71
- // is queryable when the client receives the SSE complete event.
72
- backend
73
- .init()
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
  });
@@ -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
@@ -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
- let found = null;
717
- // Normalize: if a full path is passed, extract just the basename.
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.find((r) => r.name === normalizedName) || null;
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(normalizedName, true, req);
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
- app.get('/api/repo', async (req, res) => {
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
- if (cloneDir) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.17",
3
+ "version": "1.6.10-rc.18",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -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