gitnexus 1.6.8-rc.2 → 1.6.8-rc.21
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/README.md +20 -0
- package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
- package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
- package/dist/_shared/scope-resolution/symbol-definition.d.ts +4 -0
- package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
- package/dist/cli/ai-context.js +1 -0
- package/dist/cli/analyze-config.js +40 -0
- package/dist/cli/analyze.d.ts +22 -0
- package/dist/cli/analyze.js +36 -6
- package/dist/cli/clean.d.ts +1 -0
- package/dist/cli/clean.js +43 -1
- package/dist/cli/eval-server.js +43 -0
- package/dist/cli/help-i18n.js +11 -0
- package/dist/cli/i18n/en.d.ts +13 -0
- package/dist/cli/i18n/en.js +13 -0
- package/dist/cli/i18n/resources.d.ts +26 -0
- package/dist/cli/i18n/zh-CN.d.ts +13 -0
- package/dist/cli/i18n/zh-CN.js +13 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/list.js +12 -0
- package/dist/cli/optional-grammars.d.ts +6 -8
- package/dist/cli/optional-grammars.js +8 -11
- package/dist/cli/skill-gen.js +1 -0
- package/dist/cli/status.js +26 -5
- package/dist/cli/tool.d.ts +12 -1
- package/dist/cli/tool.js +41 -1
- package/dist/core/embeddings/embedder.js +4 -0
- package/dist/core/embeddings/embedding-pipeline.js +27 -16
- package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
- package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
- package/dist/core/graph/import-cycles.d.ts +10 -0
- package/dist/core/graph/import-cycles.js +103 -0
- package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
- package/dist/core/group/extractors/http-patterns/java.js +3 -50
- package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
- package/dist/core/group/extractors/include-extractor.js +7 -7
- package/dist/core/ingestion/cfg/cfg-builder.d.ts +64 -0
- package/dist/core/ingestion/cfg/cfg-builder.js +129 -0
- package/dist/core/ingestion/cfg/collect.d.ts +30 -0
- package/dist/core/ingestion/cfg/collect.js +34 -0
- package/dist/core/ingestion/cfg/control-flow-context.d.ts +97 -0
- package/dist/core/ingestion/cfg/control-flow-context.js +113 -0
- package/dist/core/ingestion/cfg/emit.d.ts +144 -0
- package/dist/core/ingestion/cfg/emit.js +315 -0
- package/dist/core/ingestion/cfg/reaching-defs.d.ts +90 -0
- package/dist/core/ingestion/cfg/reaching-defs.js +364 -0
- package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
- package/dist/core/ingestion/cfg/traversal-result.js +2 -0
- package/dist/core/ingestion/cfg/types.d.ts +231 -0
- package/dist/core/ingestion/cfg/types.js +13 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +144 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1032 -0
- package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
- package/dist/core/ingestion/cfg/visitors/typescript.js +584 -0
- package/dist/core/ingestion/language-provider.d.ts +36 -2
- package/dist/core/ingestion/languages/c-cpp.js +11 -4
- package/dist/core/ingestion/languages/cpp/arity-metadata.js +6 -2
- package/dist/core/ingestion/languages/cpp/captures.js +24 -1
- package/dist/core/ingestion/languages/cpp/query.js +23 -0
- package/dist/core/ingestion/languages/java.js +3 -0
- package/dist/core/ingestion/languages/kotlin/query.js +3 -2
- package/dist/core/ingestion/languages/kotlin.js +5 -1
- package/dist/core/ingestion/languages/typescript.js +5 -0
- package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
- package/dist/core/ingestion/method-extractors/generic.js +1 -0
- package/dist/core/ingestion/method-types.d.ts +2 -0
- package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
- package/dist/core/ingestion/model/symbol-table.js +1 -0
- package/dist/core/ingestion/parsing-processor.js +22 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
- package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
- package/dist/core/ingestion/pipeline.d.ts +57 -0
- package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
- package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
- package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
- package/dist/core/ingestion/route-extractors/spring.js +136 -0
- package/dist/core/ingestion/scope-extractor.js +3 -0
- package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +12 -0
- package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +145 -24
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -0
- package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +21 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +207 -0
- package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
- package/dist/core/ingestion/taint/emit.d.ts +124 -0
- package/dist/core/ingestion/taint/emit.js +204 -0
- package/dist/core/ingestion/taint/match.d.ts +153 -0
- package/dist/core/ingestion/taint/match.js +278 -0
- package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
- package/dist/core/ingestion/taint/path-codec.js +190 -0
- package/dist/core/ingestion/taint/propagate.d.ts +216 -0
- package/dist/core/ingestion/taint/propagate.js +664 -0
- package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
- package/dist/core/ingestion/taint/site-safety.js +98 -0
- package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
- package/dist/core/ingestion/taint/source-sink-config.js +11 -11
- package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
- package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
- package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
- package/dist/core/ingestion/taint/typescript-model.js +102 -0
- package/dist/core/ingestion/utils/method-props.js +1 -0
- package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
- package/dist/core/ingestion/workers/clone-safety.js +465 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
- package/dist/core/ingestion/workers/parse-worker.js +70 -53
- package/dist/core/ingestion/workers/post-result.d.ts +22 -0
- package/dist/core/ingestion/workers/post-result.js +87 -0
- package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
- package/dist/core/ingestion/workers/result-merge.js +43 -0
- package/dist/core/ingestion/workers/worker-pool.d.ts +15 -0
- package/dist/core/ingestion/workers/worker-pool.js +33 -16
- package/dist/core/lbug/lbug-adapter.d.ts +19 -0
- package/dist/core/lbug/lbug-adapter.js +56 -1
- package/dist/core/run-analyze.d.ts +87 -0
- package/dist/core/run-analyze.js +280 -25
- package/dist/core/tree-sitter/parser-loader.js +5 -4
- package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
- package/dist/core/tree-sitter/vendored-grammars.js +57 -0
- package/dist/mcp/core/embedder.js +4 -0
- package/dist/mcp/local/local-backend.d.ts +86 -2
- package/dist/mcp/local/local-backend.js +657 -27
- package/dist/mcp/resources.js +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.js +109 -0
- package/dist/server/analyze-launch.d.ts +29 -0
- package/dist/server/analyze-launch.js +137 -0
- package/dist/server/analyze-upload.d.ts +33 -0
- package/dist/server/analyze-upload.js +123 -0
- package/dist/server/analyze-worker-ipc.d.ts +58 -0
- package/dist/server/analyze-worker-ipc.js +16 -0
- package/dist/server/analyze-worker.d.ts +17 -1
- package/dist/server/analyze-worker.js +7 -1
- package/dist/server/api.js +46 -140
- package/dist/server/git-clone.d.ts +1 -0
- package/dist/server/git-clone.js +1 -1
- package/dist/server/middleware.d.ts +11 -0
- package/dist/server/middleware.js +27 -0
- package/dist/server/upload-ingest.d.ts +56 -0
- package/dist/server/upload-ingest.js +276 -0
- package/dist/server/upload-paths.d.ts +31 -0
- package/dist/server/upload-paths.js +51 -0
- package/dist/server/upload-sweep.d.ts +20 -0
- package/dist/server/upload-sweep.js +57 -0
- package/dist/storage/branch-index.d.ts +52 -0
- package/dist/storage/branch-index.js +65 -0
- package/dist/storage/git.d.ts +11 -0
- package/dist/storage/git.js +28 -0
- package/dist/storage/parse-cache.d.ts +22 -1
- package/dist/storage/parse-cache.js +32 -10
- package/dist/storage/repo-manager.d.ts +122 -10
- package/dist/storage/repo-manager.js +162 -21
- package/hooks/antigravity/gitnexus-antigravity-hook.cjs +42 -8
- package/hooks/claude/gitnexus-hook.cjs +36 -8
- package/hooks/claude/hook-db-lock-probe.cjs +123 -2
- package/package.json +4 -2
- package/scripts/assert-publish-grammar-coverage.cjs +31 -0
- package/scripts/build-tree-sitter-grammars.cjs +16 -10
- package/skills/gitnexus-guide.md +11 -0
- package/vendor/tree-sitter-c/package.json +1 -1
- package/vendor/tree-sitter-dart/package.json +1 -1
- package/vendor/tree-sitter-kotlin/package.json +1 -1
- package/vendor/tree-sitter-proto/package.json +1 -1
- package/vendor/tree-sitter-swift/package.json +1 -1
- package/web/assets/{agent-CKPMqImC.js → agent-ay4LD70X.js} +1 -1
- package/web/assets/{architectureDiagram-UL44E2DR-D1EXI0zA.js → architectureDiagram-UL44E2DR-Dc-viYhd.js} +1 -1
- package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-4rpojOyj.js} +1 -1
- package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-BnOy944n.js} +1 -1
- package/web/assets/{classDiagram-KGZ6W3CR-Bv93af_b.js → classDiagram-KGZ6W3CR-Bsgpy98Q.js} +1 -1
- package/web/assets/{classDiagram-v2-72OJOZXJ-CLfEqHUa.js → classDiagram-v2-72OJOZXJ-D5atDGjc.js} +1 -1
- package/web/assets/{diagram-3NCE3AQN-BqAtKUpW.js → diagram-3NCE3AQN-Cz1OEMVi.js} +1 -1
- package/web/assets/{diagram-GF46GFSD-CaBG6n6o.js → diagram-GF46GFSD-CWYwzfP8.js} +1 -1
- package/web/assets/{diagram-QXG6HAR7-CZ-O3rcV.js → diagram-QXG6HAR7-DtBObr8L.js} +1 -1
- package/web/assets/{diagram-WEQXMOUZ-P4lSL4GH.js → diagram-WEQXMOUZ-BUV44Ov_.js} +1 -1
- package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-Ds9s-sRF.js} +1 -1
- package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-CupStHQb.js} +1 -1
- package/web/assets/index-COMMmbxW.css +2 -0
- package/web/assets/{index-CG6q8eTs.js → index-Do8AE5yF.js} +85 -85
- package/web/assets/{infoDiagram-3YFTVSEB-DmLICZx1.js → infoDiagram-3YFTVSEB-DPTXSbgu.js} +1 -1
- package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CeK-Qhet.js} +1 -1
- package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-D4tx6QQ-.js} +1 -1
- package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-Bcto_kGa.js} +1 -1
- package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-DPIeBTG6.js} +1 -1
- package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-DDGXjVKO.js} +1 -1
- package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-BzDqpcVW.js} +1 -1
- package/web/assets/{stateDiagram-7D4R322I-CB2nABwH.js → stateDiagram-7D4R322I-Bx__als3.js} +1 -1
- package/web/assets/{stateDiagram-v2-36443NZ5-COBGd2RL.js → stateDiagram-v2-36443NZ5-QqOh2yO-.js} +1 -1
- package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-C1eogTOG.js} +1 -1
- package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-D20F4rSW.js} +1 -1
- package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-BeBHg7ST.js} +1 -1
- package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-CdOWeoNI.js} +1 -1
- package/web/index.html +2 -2
- package/scripts/materialize-vendor-grammars.cjs +0 -97
- package/web/assets/index-BKWA-m7o.css +0 -2
package/dist/server/api.js
CHANGED
|
@@ -20,11 +20,15 @@ import { searchFTSFromLbug } from '../core/search/bm25-index.js';
|
|
|
20
20
|
import { hybridSearch } from '../core/search/hybrid-search.js';
|
|
21
21
|
import { LocalBackend } from '../mcp/local/local-backend.js';
|
|
22
22
|
import { mountMCPEndpoints } from './mcp-http.js';
|
|
23
|
-
import {
|
|
24
|
-
import { fileURLToPath, pathToFileURL } from 'url';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
25
24
|
import { JobManager } from './analyze-job.js';
|
|
26
25
|
import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
|
|
27
26
|
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
|
|
27
|
+
import { createAnalyzeUploadHandler } from './analyze-upload.js';
|
|
28
|
+
import { requireLocalhostOrigin } from './middleware.js';
|
|
29
|
+
import { createLaunchAnalysisWorker } from './analyze-launch.js';
|
|
30
|
+
import { UPLOAD_ROOT } from './upload-paths.js';
|
|
31
|
+
import { sweepStaleUploads } from './upload-sweep.js';
|
|
28
32
|
import { logger, flushLoggerSync } from '../core/logger.js';
|
|
29
33
|
const _require = createRequire(import.meta.url);
|
|
30
34
|
const pkg = _require('../../package.json');
|
|
@@ -640,6 +644,8 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
640
644
|
await backend.init();
|
|
641
645
|
const cleanupMcp = mountMCPEndpoints(app, backend);
|
|
642
646
|
const jobManager = new JobManager();
|
|
647
|
+
// Backstop: remove any upload staging dirs orphaned by a previous crash.
|
|
648
|
+
void sweepStaleUploads().catch(() => { });
|
|
643
649
|
// Shared repo lock — prevents concurrent analyze + embed on the same repo path,
|
|
644
650
|
// which would corrupt LadybugDB (analyze calls closeLbug + initLbug while embed has queries in flight).
|
|
645
651
|
const activeRepoPaths = new Set();
|
|
@@ -653,6 +659,14 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
653
659
|
const releaseRepoLock = (repoPath) => {
|
|
654
660
|
activeRepoPaths.delete(repoPath);
|
|
655
661
|
};
|
|
662
|
+
// Launch the analyze worker for an already-resolved repo directory. Shared by
|
|
663
|
+
// the JSON /api/analyze route and the multipart /api/analyze/upload route.
|
|
664
|
+
const launchAnalysisWorker = createLaunchAnalysisWorker({
|
|
665
|
+
jobManager,
|
|
666
|
+
backend,
|
|
667
|
+
acquireRepoLock,
|
|
668
|
+
releaseRepoLock,
|
|
669
|
+
});
|
|
656
670
|
/**
|
|
657
671
|
* Maximum time the hold-queue will wait for an active analysis job to complete.
|
|
658
672
|
* Must stay in sync with the frontend's `fetchRepoInfo({ awaitAnalysis: true })` timeout.
|
|
@@ -867,6 +881,13 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
867
881
|
/* clone dir may not exist */
|
|
868
882
|
}
|
|
869
883
|
}
|
|
884
|
+
// 2b. Delete the uploaded repo dir if entry.path lives under
|
|
885
|
+
// UPLOAD_ROOT. Drive this off entry.path (not a name-rederived dir) so
|
|
886
|
+
// a same-named clone is never affected.
|
|
887
|
+
const resolvedEntry = path.resolve(entry.path);
|
|
888
|
+
if (resolvedEntry === UPLOAD_ROOT || resolvedEntry.startsWith(UPLOAD_ROOT + path.sep)) {
|
|
889
|
+
await fs.rm(resolvedEntry, { recursive: true, force: true }).catch(() => { });
|
|
890
|
+
}
|
|
870
891
|
// 3. Unregister from the global registry
|
|
871
892
|
const { unregisterRepo } = await import('../storage/repo-manager.js');
|
|
872
893
|
await unregisterRepo(entry.path);
|
|
@@ -1251,7 +1272,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
1251
1272
|
});
|
|
1252
1273
|
// ── Analyze API ──────────────────────────────────────────────────────
|
|
1253
1274
|
// POST /api/analyze — start a new analysis job
|
|
1254
|
-
app.post('/api/analyze', createRouteLimiter({ limit: 10 }), async (req, res) => {
|
|
1275
|
+
app.post('/api/analyze', createRouteLimiter({ limit: 10 }), requireLocalhostOrigin, async (req, res) => {
|
|
1255
1276
|
try {
|
|
1256
1277
|
const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings } = req.body;
|
|
1257
1278
|
// Input type validation
|
|
@@ -1267,16 +1288,18 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
1267
1288
|
res.status(400).json({ error: 'Provide "url" (git URL) or "path" (local path)' });
|
|
1268
1289
|
return;
|
|
1269
1290
|
}
|
|
1270
|
-
// Path validation
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1291
|
+
// Path validation. The previous `normalize !== resolve` guard was inert
|
|
1292
|
+
// (both collapse `..` identically) and only false-rejected trailing
|
|
1293
|
+
// slashes, so it is dropped. Analyzing a local path the operator names
|
|
1294
|
+
// is the tool's intended capability (same as the CLI); the dangerous
|
|
1295
|
+
// part was cross-origin reach, which is closed by requireLocalhostOrigin
|
|
1296
|
+
// on this route. We only require an absolute path here and let the
|
|
1297
|
+
// analyze worker surface a clear error if it does not exist. (We do NOT
|
|
1298
|
+
// realpath/stat the path in-route: that would be a user-controlled
|
|
1299
|
+
// filesystem read — CodeQL js/path-injection — for no security gain.)
|
|
1300
|
+
if (repoLocalPath && !path.isAbsolute(repoLocalPath)) {
|
|
1301
|
+
res.status(400).json({ error: '"path" must be an absolute path' });
|
|
1302
|
+
return;
|
|
1280
1303
|
}
|
|
1281
1304
|
const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
|
|
1282
1305
|
// If job was already running (dedup), just return its id
|
|
@@ -1308,133 +1331,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
1308
1331
|
if (!targetPath) {
|
|
1309
1332
|
throw new Error('No target path resolved');
|
|
1310
1333
|
}
|
|
1311
|
-
|
|
1312
|
-
const analyzeLockKey = getStoragePath(targetPath);
|
|
1313
|
-
const lockErr = acquireRepoLock(analyzeLockKey);
|
|
1314
|
-
if (lockErr) {
|
|
1315
|
-
jobManager.updateJob(job.id, { status: 'failed', error: lockErr });
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1318
|
-
jobManager.updateJob(job.id, { repoPath: targetPath, status: 'analyzing' });
|
|
1319
|
-
// ── Worker fork with auto-retry ──────────────────────────────
|
|
1320
|
-
//
|
|
1321
|
-
// Forks a child process with 8GB heap. If the worker crashes
|
|
1322
|
-
// (OOM, native addon segfault, etc.), it retries up to
|
|
1323
|
-
// MAX_WORKER_RETRIES times with exponential backoff before
|
|
1324
|
-
// marking the job as permanently failed.
|
|
1325
|
-
//
|
|
1326
|
-
// In dev mode (tsx), registers the tsx ESM hook via a file://
|
|
1327
|
-
// URL so the child can compile TypeScript on-the-fly.
|
|
1328
|
-
const MAX_WORKER_RETRIES = 2;
|
|
1329
|
-
const callerPath = fileURLToPath(import.meta.url);
|
|
1330
|
-
const isDev = callerPath.endsWith('.ts');
|
|
1331
|
-
const workerFile = isDev ? 'analyze-worker.ts' : 'analyze-worker.js';
|
|
1332
|
-
const workerPath = path.join(path.dirname(callerPath), workerFile);
|
|
1333
|
-
const tsxHookArgs = isDev
|
|
1334
|
-
? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href]
|
|
1335
|
-
: [];
|
|
1336
|
-
const forkWorker = () => {
|
|
1337
|
-
const currentJob = jobManager.getJob(job.id);
|
|
1338
|
-
if (!currentJob || currentJob.status === 'complete' || currentJob.status === 'failed')
|
|
1339
|
-
return;
|
|
1340
|
-
const child = fork(workerPath, [], {
|
|
1341
|
-
execArgv: [...tsxHookArgs, '--max-old-space-size=8192'],
|
|
1342
|
-
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
1343
|
-
});
|
|
1344
|
-
// Capture stderr for crash diagnostics
|
|
1345
|
-
let stderrChunks = '';
|
|
1346
|
-
child.stderr?.on('data', (chunk) => {
|
|
1347
|
-
stderrChunks += chunk.toString();
|
|
1348
|
-
if (stderrChunks.length > 4096)
|
|
1349
|
-
stderrChunks = stderrChunks.slice(-4096);
|
|
1350
|
-
});
|
|
1351
|
-
child.on('message', (msg) => {
|
|
1352
|
-
if (msg.type === 'progress') {
|
|
1353
|
-
jobManager.updateJob(job.id, {
|
|
1354
|
-
status: 'analyzing',
|
|
1355
|
-
progress: { phase: msg.phase, percent: msg.percent, message: msg.message },
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
else if (msg.type === 'complete') {
|
|
1359
|
-
releaseRepoLock(analyzeLockKey);
|
|
1360
|
-
// Reinitialize backend BEFORE marking complete — ensures the new
|
|
1361
|
-
// repo is queryable when the client receives the SSE complete event.
|
|
1362
|
-
backend
|
|
1363
|
-
.init()
|
|
1364
|
-
.then(() => {
|
|
1365
|
-
jobManager.updateJob(job.id, {
|
|
1366
|
-
status: 'complete',
|
|
1367
|
-
repoName: msg.result.repoName,
|
|
1368
|
-
});
|
|
1369
|
-
})
|
|
1370
|
-
.catch((err) => {
|
|
1371
|
-
logger.error({ err }, 'backend.init() failed after analyze:');
|
|
1372
|
-
jobManager.updateJob(job.id, {
|
|
1373
|
-
status: 'failed',
|
|
1374
|
-
error: 'Server failed to reload after analysis. Try again.',
|
|
1375
|
-
});
|
|
1376
|
-
});
|
|
1377
|
-
}
|
|
1378
|
-
else if (msg.type === 'error') {
|
|
1379
|
-
releaseRepoLock(analyzeLockKey);
|
|
1380
|
-
jobManager.updateJob(job.id, {
|
|
1381
|
-
status: 'failed',
|
|
1382
|
-
error: msg.message,
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
});
|
|
1386
|
-
child.on('error', (err) => {
|
|
1387
|
-
releaseRepoLock(analyzeLockKey);
|
|
1388
|
-
jobManager.updateJob(job.id, {
|
|
1389
|
-
status: 'failed',
|
|
1390
|
-
error: `Worker process error: ${err.message}`,
|
|
1391
|
-
});
|
|
1392
|
-
});
|
|
1393
|
-
child.on('exit', (code) => {
|
|
1394
|
-
const j = jobManager.getJob(job.id);
|
|
1395
|
-
if (!j || j.status === 'complete' || j.status === 'failed')
|
|
1396
|
-
return;
|
|
1397
|
-
// Worker crashed — attempt retry if under the limit
|
|
1398
|
-
if (j.retryCount < MAX_WORKER_RETRIES) {
|
|
1399
|
-
j.retryCount++;
|
|
1400
|
-
const delay = 1000 * Math.pow(2, j.retryCount - 1); // 1s, 2s
|
|
1401
|
-
const lastErr = stderrChunks.trim().split('\n').pop() || '';
|
|
1402
|
-
logger.warn(`Analyze worker crashed (code ${code}), retry ${j.retryCount}/${MAX_WORKER_RETRIES} in ${delay}ms` +
|
|
1403
|
-
(lastErr ? `: ${lastErr}` : ''));
|
|
1404
|
-
jobManager.updateJob(job.id, {
|
|
1405
|
-
status: 'analyzing',
|
|
1406
|
-
progress: {
|
|
1407
|
-
phase: 'retrying',
|
|
1408
|
-
percent: j.progress.percent,
|
|
1409
|
-
message: `Worker crashed, retrying (${j.retryCount}/${MAX_WORKER_RETRIES})...`,
|
|
1410
|
-
},
|
|
1411
|
-
});
|
|
1412
|
-
stderrChunks = '';
|
|
1413
|
-
setTimeout(forkWorker, delay);
|
|
1414
|
-
}
|
|
1415
|
-
else {
|
|
1416
|
-
// Exhausted retries — permanent failure
|
|
1417
|
-
releaseRepoLock(analyzeLockKey);
|
|
1418
|
-
jobManager.updateJob(job.id, {
|
|
1419
|
-
status: 'failed',
|
|
1420
|
-
error: `Worker crashed ${MAX_WORKER_RETRIES + 1} times (code ${code})${stderrChunks ? ': ' + stderrChunks.trim().split('\n').pop() : ''}`,
|
|
1421
|
-
});
|
|
1422
|
-
}
|
|
1423
|
-
});
|
|
1424
|
-
// Register child for cancellation + timeout tracking
|
|
1425
|
-
jobManager.registerChild(job.id, child);
|
|
1426
|
-
// Send start command to child
|
|
1427
|
-
child.send({
|
|
1428
|
-
type: 'start',
|
|
1429
|
-
repoPath: targetPath,
|
|
1430
|
-
options: {
|
|
1431
|
-
force: !!force,
|
|
1432
|
-
embeddings: !!embeddings,
|
|
1433
|
-
dropEmbeddings: !!dropEmbeddings,
|
|
1434
|
-
},
|
|
1435
|
-
});
|
|
1436
|
-
};
|
|
1437
|
-
forkWorker();
|
|
1334
|
+
launchAnalysisWorker(job, targetPath, { force, embeddings, dropEmbeddings });
|
|
1438
1335
|
}
|
|
1439
1336
|
catch (err) {
|
|
1440
1337
|
if (targetPath)
|
|
@@ -1456,6 +1353,15 @@ export const createServer = async (port, host = '127.0.0.1') => {
|
|
|
1456
1353
|
}
|
|
1457
1354
|
}
|
|
1458
1355
|
});
|
|
1356
|
+
// POST /api/analyze/upload — analyze a browser folder upload.
|
|
1357
|
+
// Securely ingests the multipart upload into a sandbox, promotes it to a
|
|
1358
|
+
// persistent dir, and analyzes it via the shared job/worker machinery.
|
|
1359
|
+
// localhost-only (no cross-origin write reach) + conservative rate limit.
|
|
1360
|
+
app.post('/api/analyze/upload', createRouteLimiter({ limit: 5 }), requireLocalhostOrigin, createAnalyzeUploadHandler({
|
|
1361
|
+
createJob: (params) => jobManager.createJob(params),
|
|
1362
|
+
launch: (job, targetPath, opts) => launchAnalysisWorker(job, targetPath, opts),
|
|
1363
|
+
failJob: (jobId, error) => jobManager.updateJob(jobId, { status: 'failed', error }),
|
|
1364
|
+
}));
|
|
1459
1365
|
// GET /api/analyze/:jobId — poll job status
|
|
1460
1366
|
app.get('/api/analyze/:jobId', (req, res) => {
|
|
1461
1367
|
const job = jobManager.getJob(req.params.jobId);
|
package/dist/server/git-clone.js
CHANGED
|
@@ -17,7 +17,7 @@ const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos'));
|
|
|
17
17
|
// Rejecting anything else (including `..`, `/`, `\`, shell metacharacters)
|
|
18
18
|
// guarantees getCloneDir(repoName) cannot escape CLONE_ROOT regardless of
|
|
19
19
|
// how the caller derived repoName.
|
|
20
|
-
const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
20
|
+
export const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
21
21
|
/**
|
|
22
22
|
* Extract the repository name from a git URL (HTTPS or SSH).
|
|
23
23
|
*
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Express route guards (alongside createRouteLimiter in validation.ts).
|
|
3
|
+
*/
|
|
4
|
+
import type { Request, Response } from 'express';
|
|
5
|
+
/**
|
|
6
|
+
* Restrict a route to localhost browser origins. Non-browser requests (no
|
|
7
|
+
* Origin header, e.g. curl / the CLI) pass through. This closes cross-origin
|
|
8
|
+
* reach (the allow-listed public deploy + Private Network Access) to write
|
|
9
|
+
* routes without affecting read routes.
|
|
10
|
+
*/
|
|
11
|
+
export declare function requireLocalhostOrigin(req: Request, res: Response, next: () => void): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Express route guards (alongside createRouteLimiter in validation.ts).
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Restrict a route to localhost browser origins. Non-browser requests (no
|
|
6
|
+
* Origin header, e.g. curl / the CLI) pass through. This closes cross-origin
|
|
7
|
+
* reach (the allow-listed public deploy + Private Network Access) to write
|
|
8
|
+
* routes without affecting read routes.
|
|
9
|
+
*/
|
|
10
|
+
export function requireLocalhostOrigin(req, res, next) {
|
|
11
|
+
const origin = req.headers.origin;
|
|
12
|
+
if (origin === undefined) {
|
|
13
|
+
next();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const hostname = new URL(origin).hostname;
|
|
18
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
|
19
|
+
next();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* malformed origin → reject */
|
|
25
|
+
}
|
|
26
|
+
res.status(403).json({ error: 'This endpoint is restricted to localhost origins' });
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure ingestion of a browser folder upload (multipart/form-data).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the path-injection-prone GET /api/fs/list directory listing. The
|
|
5
|
+
* client streams the selected files plus a JSON `manifest` of their
|
|
6
|
+
* webkitRelativePaths; we write each into an mkdtemp staging dir under
|
|
7
|
+
* UPLOAD_ROOT with PROVABLE containment (resolve-then-contain), hard resource
|
|
8
|
+
* caps, and guaranteed cleanup on every failure/abort path. No client value
|
|
9
|
+
* ever reaches a filesystem READ — the server only writes into a sandbox it
|
|
10
|
+
* created, then hands that sandbox to the analysis pipeline.
|
|
11
|
+
*
|
|
12
|
+
* Security references: CodeQL js/path-injection (resolve + startsWith(root+sep)),
|
|
13
|
+
* OWASP File Upload / Path Traversal.
|
|
14
|
+
*/
|
|
15
|
+
import type { IncomingMessage } from 'http';
|
|
16
|
+
export interface IngestLimits {
|
|
17
|
+
/** Aggregate bytes across all files (busboy has no aggregate limit). */
|
|
18
|
+
maxTotalBytes: number;
|
|
19
|
+
/** Per-file byte cap. */
|
|
20
|
+
maxFileBytes: number;
|
|
21
|
+
/** Maximum number of files. */
|
|
22
|
+
maxFiles: number;
|
|
23
|
+
/** Maximum multipart parts (files + fields). */
|
|
24
|
+
maxParts: number;
|
|
25
|
+
/** Maximum directories created (inode-exhaustion guard). */
|
|
26
|
+
maxDirs: number;
|
|
27
|
+
/** Maximum size of the manifest field. */
|
|
28
|
+
maxFieldBytes: number;
|
|
29
|
+
}
|
|
30
|
+
export declare const DEFAULT_INGEST_LIMITS: IngestLimits;
|
|
31
|
+
export interface IngestResult {
|
|
32
|
+
/** Absolute path to the populated staging directory (realpath-canonical). */
|
|
33
|
+
stageRoot: string;
|
|
34
|
+
fileCount: number;
|
|
35
|
+
totalBytes: number;
|
|
36
|
+
/** First path segment shared by the uploaded tree (the picked folder). */
|
|
37
|
+
topLevelName: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a client-provided relative path to an absolute destination PROVABLY
|
|
41
|
+
* contained within `stageRoot`. Throws BadRequestError on any unsafe input.
|
|
42
|
+
* This is the load-bearing path-traversal-on-write control; keep it pure and
|
|
43
|
+
* unit-tested.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveContainedDest(stageRoot: string, rel: unknown): string;
|
|
46
|
+
export interface IngestOptions {
|
|
47
|
+
/** Override the staging parent dir (defaults to UPLOAD_ROOT; for tests). */
|
|
48
|
+
root?: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Parse and securely write a multipart folder upload into a fresh staging
|
|
52
|
+
* directory under UPLOAD_ROOT. Resolves with the populated staging dir, or
|
|
53
|
+
* rejects with a BadRequestError (status 400/413) after removing the staging
|
|
54
|
+
* dir. The caller owns promotion + cleanup of the returned `stageRoot`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function ingestUpload(req: IncomingMessage, limitsOverride?: Partial<IngestLimits>, opts?: IngestOptions): Promise<IngestResult>;
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure ingestion of a browser folder upload (multipart/form-data).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the path-injection-prone GET /api/fs/list directory listing. The
|
|
5
|
+
* client streams the selected files plus a JSON `manifest` of their
|
|
6
|
+
* webkitRelativePaths; we write each into an mkdtemp staging dir under
|
|
7
|
+
* UPLOAD_ROOT with PROVABLE containment (resolve-then-contain), hard resource
|
|
8
|
+
* caps, and guaranteed cleanup on every failure/abort path. No client value
|
|
9
|
+
* ever reaches a filesystem READ — the server only writes into a sandbox it
|
|
10
|
+
* created, then hands that sandbox to the analysis pipeline.
|
|
11
|
+
*
|
|
12
|
+
* Security references: CodeQL js/path-injection (resolve + startsWith(root+sep)),
|
|
13
|
+
* OWASP File Upload / Path Traversal.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import fsp from 'fs/promises';
|
|
18
|
+
import busboy from 'busboy';
|
|
19
|
+
import { UPLOAD_ROOT, STAGING_PREFIX } from './upload-paths.js';
|
|
20
|
+
import { BadRequestError } from './validation.js';
|
|
21
|
+
export const DEFAULT_INGEST_LIMITS = {
|
|
22
|
+
maxTotalBytes: 250 * 1024 * 1024,
|
|
23
|
+
maxFileBytes: 25 * 1024 * 1024,
|
|
24
|
+
maxFiles: 20000,
|
|
25
|
+
maxParts: 20100,
|
|
26
|
+
maxDirs: 50000,
|
|
27
|
+
maxFieldBytes: 2 * 1024 * 1024,
|
|
28
|
+
};
|
|
29
|
+
const MAX_PATH_DEPTH = 64;
|
|
30
|
+
const MAX_PATH_LENGTH = 4096;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a client-provided relative path to an absolute destination PROVABLY
|
|
33
|
+
* contained within `stageRoot`. Throws BadRequestError on any unsafe input.
|
|
34
|
+
* This is the load-bearing path-traversal-on-write control; keep it pure and
|
|
35
|
+
* unit-tested.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveContainedDest(stageRoot, rel) {
|
|
38
|
+
if (typeof rel !== 'string' || rel.length === 0) {
|
|
39
|
+
throw new BadRequestError('Invalid upload path');
|
|
40
|
+
}
|
|
41
|
+
if (rel.length > MAX_PATH_LENGTH) {
|
|
42
|
+
throw new BadRequestError('Upload path too long');
|
|
43
|
+
}
|
|
44
|
+
// webkitRelativePath is always relative; a leading slash is absolute/hostile.
|
|
45
|
+
if (rel.startsWith('/')) {
|
|
46
|
+
throw new BadRequestError('Invalid upload path');
|
|
47
|
+
}
|
|
48
|
+
// Browsers emit forward slashes only; a NUL byte or backslash is hostile.
|
|
49
|
+
if (rel.includes('\u0000') || rel.includes('\\')) {
|
|
50
|
+
throw new BadRequestError('Invalid upload path');
|
|
51
|
+
}
|
|
52
|
+
const rawSegments = rel.split('/').filter((s) => s.length > 0);
|
|
53
|
+
if (rawSegments.length === 0 || rawSegments.length > MAX_PATH_DEPTH) {
|
|
54
|
+
throw new BadRequestError('Invalid upload path');
|
|
55
|
+
}
|
|
56
|
+
const segments = [];
|
|
57
|
+
for (const seg of rawSegments) {
|
|
58
|
+
// Normalize so NFC/NFD variants don't collide silently on case/unicode
|
|
59
|
+
// -folding filesystems (macOS/Windows).
|
|
60
|
+
const s = seg.normalize('NFC');
|
|
61
|
+
if (s === '.' || s === '..') {
|
|
62
|
+
throw new BadRequestError('Upload path must not contain traversal segments');
|
|
63
|
+
}
|
|
64
|
+
segments.push(s);
|
|
65
|
+
}
|
|
66
|
+
const dest = path.resolve(stageRoot, segments.join(path.sep));
|
|
67
|
+
// Suffix path.sep so a sibling prefix (/sandbox-evil vs /sandbox) can't pass.
|
|
68
|
+
if (dest !== stageRoot && !dest.startsWith(stageRoot + path.sep)) {
|
|
69
|
+
throw new BadRequestError('Upload path escapes the sandbox');
|
|
70
|
+
}
|
|
71
|
+
return dest;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Create the parent directories of `destFile` one segment at a time, asserting
|
|
75
|
+
* after each `mkdir` that the segment is a real directory (not a symlink
|
|
76
|
+
* swapped in mid-stream) still inside `stageRoot`. Counts created dirs against
|
|
77
|
+
* the inode-exhaustion cap.
|
|
78
|
+
*/
|
|
79
|
+
function mkdirContained(stageRoot, destFile, state) {
|
|
80
|
+
const parent = path.dirname(destFile);
|
|
81
|
+
const relParent = path.relative(stageRoot, parent);
|
|
82
|
+
if (relParent === '' || relParent === '.')
|
|
83
|
+
return;
|
|
84
|
+
const segs = relParent.split(path.sep).filter(Boolean);
|
|
85
|
+
let cur = stageRoot;
|
|
86
|
+
for (const seg of segs) {
|
|
87
|
+
cur = path.join(cur, seg);
|
|
88
|
+
let made = false;
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(cur);
|
|
91
|
+
made = true;
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
if (err.code !== 'EEXIST')
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
const st = fs.lstatSync(cur);
|
|
98
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
99
|
+
throw new BadRequestError('Upload path escapes the sandbox');
|
|
100
|
+
}
|
|
101
|
+
if (made) {
|
|
102
|
+
state.dirCount++;
|
|
103
|
+
if (state.dirCount > state.limits.maxDirs) {
|
|
104
|
+
throw new BadRequestError('Too many directories in upload', 413);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Parse and securely write a multipart folder upload into a fresh staging
|
|
111
|
+
* directory under UPLOAD_ROOT. Resolves with the populated staging dir, or
|
|
112
|
+
* rejects with a BadRequestError (status 400/413) after removing the staging
|
|
113
|
+
* dir. The caller owns promotion + cleanup of the returned `stageRoot`.
|
|
114
|
+
*/
|
|
115
|
+
export async function ingestUpload(req, limitsOverride, opts = {}) {
|
|
116
|
+
const limits = { ...DEFAULT_INGEST_LIMITS, ...limitsOverride };
|
|
117
|
+
const uploadRoot = opts.root ?? UPLOAD_ROOT;
|
|
118
|
+
await fsp.mkdir(uploadRoot, { recursive: true });
|
|
119
|
+
// mkdtemp creates the dir mode 0o700 (owner-only); realpath canonicalizes
|
|
120
|
+
// the root so the containment prefix check is exact.
|
|
121
|
+
const stageRoot = await fsp.realpath(await fsp.mkdtemp(path.join(uploadRoot, STAGING_PREFIX)));
|
|
122
|
+
let cleaned = false;
|
|
123
|
+
const cleanup = async () => {
|
|
124
|
+
if (cleaned)
|
|
125
|
+
return;
|
|
126
|
+
cleaned = true;
|
|
127
|
+
await fsp.rm(stageRoot, { recursive: true, force: true }).catch(() => { });
|
|
128
|
+
};
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
let settled = false;
|
|
131
|
+
let manifest = null;
|
|
132
|
+
let fileIndex = 0;
|
|
133
|
+
let fileCount = 0;
|
|
134
|
+
let totalBytes = 0;
|
|
135
|
+
let topLevelName = '';
|
|
136
|
+
const dirState = { dirCount: 0, limits };
|
|
137
|
+
const writePromises = [];
|
|
138
|
+
const bb = busboy({
|
|
139
|
+
headers: req.headers,
|
|
140
|
+
limits: {
|
|
141
|
+
fileSize: limits.maxFileBytes,
|
|
142
|
+
files: limits.maxFiles,
|
|
143
|
+
parts: limits.maxParts,
|
|
144
|
+
fields: 10,
|
|
145
|
+
fieldNameSize: 200,
|
|
146
|
+
fieldSize: limits.maxFieldBytes,
|
|
147
|
+
headerPairs: 2000,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const fail = (err) => {
|
|
151
|
+
if (settled)
|
|
152
|
+
return;
|
|
153
|
+
settled = true;
|
|
154
|
+
try {
|
|
155
|
+
req.unpipe(bb);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* ignore */
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
req.resume(); // drain remaining body so the socket isn't left hanging
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* ignore */
|
|
165
|
+
}
|
|
166
|
+
void cleanup().finally(() => reject(err));
|
|
167
|
+
};
|
|
168
|
+
bb.on('field', (name, val) => {
|
|
169
|
+
if (name !== 'manifest')
|
|
170
|
+
return;
|
|
171
|
+
try {
|
|
172
|
+
const parsed = JSON.parse(val);
|
|
173
|
+
if (!Array.isArray(parsed) || !parsed.every((p) => typeof p === 'string')) {
|
|
174
|
+
return fail(new BadRequestError('Invalid manifest'));
|
|
175
|
+
}
|
|
176
|
+
manifest = parsed;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
fail(new BadRequestError('Invalid manifest'));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
bb.on('file', (_name, stream, _info) => {
|
|
183
|
+
if (settled) {
|
|
184
|
+
stream.resume();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (manifest === null) {
|
|
188
|
+
// The manifest field MUST arrive before any file part.
|
|
189
|
+
stream.resume();
|
|
190
|
+
return fail(new BadRequestError('Manifest must precede file parts'));
|
|
191
|
+
}
|
|
192
|
+
const idx = fileIndex++;
|
|
193
|
+
const rel = manifest[idx];
|
|
194
|
+
let dest;
|
|
195
|
+
try {
|
|
196
|
+
dest = resolveContainedDest(stageRoot, rel);
|
|
197
|
+
// A folder upload is exactly one top-level directory: every entry must
|
|
198
|
+
// have ≥2 segments and share the same first segment. This rejects a
|
|
199
|
+
// bare file at the root (which would make the promote target a file)
|
|
200
|
+
// and a multi-top manifest (which would silently drop all but the
|
|
201
|
+
// first folder). Validated here, before any job is created.
|
|
202
|
+
const segs = String(rel)
|
|
203
|
+
.split('/')
|
|
204
|
+
.filter((s) => s.length > 0);
|
|
205
|
+
const firstSeg = (segs[0] ?? '').normalize('NFC');
|
|
206
|
+
if (!topLevelName) {
|
|
207
|
+
topLevelName = firstSeg;
|
|
208
|
+
}
|
|
209
|
+
if (segs.length < 2 || firstSeg !== topLevelName) {
|
|
210
|
+
throw new BadRequestError('Upload must be a single folder of files');
|
|
211
|
+
}
|
|
212
|
+
mkdirContained(stageRoot, dest, dirState);
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
stream.resume();
|
|
216
|
+
return fail(err);
|
|
217
|
+
}
|
|
218
|
+
fileCount++;
|
|
219
|
+
const ws = fs.createWriteStream(dest, { flags: 'wx' });
|
|
220
|
+
const p = new Promise((res, rej) => {
|
|
221
|
+
stream.on('data', (chunk) => {
|
|
222
|
+
totalBytes += chunk.length;
|
|
223
|
+
if (totalBytes > limits.maxTotalBytes) {
|
|
224
|
+
stream.unpipe(ws);
|
|
225
|
+
ws.destroy();
|
|
226
|
+
rej(new BadRequestError('Upload exceeds total size limit', 413));
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
stream.on('limit', () => {
|
|
230
|
+
ws.destroy();
|
|
231
|
+
rej(new BadRequestError('File exceeds size limit', 413));
|
|
232
|
+
});
|
|
233
|
+
stream.on('error', rej);
|
|
234
|
+
ws.on('error', rej);
|
|
235
|
+
ws.on('finish', () => res());
|
|
236
|
+
stream.pipe(ws);
|
|
237
|
+
});
|
|
238
|
+
writePromises.push(p);
|
|
239
|
+
p.catch(fail);
|
|
240
|
+
});
|
|
241
|
+
bb.on('filesLimit', () => fail(new BadRequestError('Too many files in upload', 413)));
|
|
242
|
+
bb.on('partsLimit', () => fail(new BadRequestError('Too many parts in upload', 413)));
|
|
243
|
+
bb.on('fieldsLimit', () => fail(new BadRequestError('Too many fields in upload')));
|
|
244
|
+
bb.on('error', (err) => fail(err instanceof Error ? err : new BadRequestError('Upload parse error')));
|
|
245
|
+
bb.on('close', () => {
|
|
246
|
+
if (settled)
|
|
247
|
+
return;
|
|
248
|
+
Promise.all(writePromises)
|
|
249
|
+
.then(() => {
|
|
250
|
+
if (settled)
|
|
251
|
+
return;
|
|
252
|
+
if (manifest === null)
|
|
253
|
+
return fail(new BadRequestError('Missing manifest'));
|
|
254
|
+
if (fileCount === 0)
|
|
255
|
+
return fail(new BadRequestError('Empty upload'));
|
|
256
|
+
if (fileCount !== manifest.length) {
|
|
257
|
+
return fail(new BadRequestError('Manifest/file count mismatch'));
|
|
258
|
+
}
|
|
259
|
+
if (!topLevelName) {
|
|
260
|
+
return fail(new BadRequestError('Could not determine upload folder name'));
|
|
261
|
+
}
|
|
262
|
+
settled = true;
|
|
263
|
+
resolve({ stageRoot, fileCount, totalBytes, topLevelName });
|
|
264
|
+
})
|
|
265
|
+
.catch(() => {
|
|
266
|
+
/* a write rejected → fail() already invoked via p.catch */
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
req.on('aborted', () => {
|
|
270
|
+
if (!settled)
|
|
271
|
+
fail(new BadRequestError('Upload aborted'));
|
|
272
|
+
});
|
|
273
|
+
req.on('error', (err) => fail(err instanceof Error ? err : new BadRequestError('Request error')));
|
|
274
|
+
req.pipe(bb);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upload working-directory paths.
|
|
3
|
+
*
|
|
4
|
+
* Browser folder uploads are written into ~/.gitnexus/uploads/{name}/ — a
|
|
5
|
+
* sibling of the clone root (git-clone.ts CLONE_ROOT) — so an uploaded repo
|
|
6
|
+
* persists and behaves like a cloned one (the graph UI's /api/file reads its
|
|
7
|
+
* files after analysis, and DELETE /api/repo removes it). Staging happens in
|
|
8
|
+
* an mkdtemp dir *under* UPLOAD_ROOT so the promote rename stays on one
|
|
9
|
+
* filesystem and remains atomic (a rename from os.tmpdir() could trip EXDEV —
|
|
10
|
+
* the exact Docker case this feature targets; see bridge-db.ts for the same
|
|
11
|
+
* anchored-staging pattern).
|
|
12
|
+
*/
|
|
13
|
+
/** Root directory for all uploaded repositories. Targets must resolve inside this. */
|
|
14
|
+
export declare const UPLOAD_ROOT: string;
|
|
15
|
+
/** Prefix for per-upload staging directories created under UPLOAD_ROOT. */
|
|
16
|
+
export declare const STAGING_PREFIX = ".staging-";
|
|
17
|
+
/**
|
|
18
|
+
* Get the upload target directory for a repo name.
|
|
19
|
+
*
|
|
20
|
+
* Re-validates at the boundary (callers may derive the name from an untrusted
|
|
21
|
+
* manifest). Rejects `.`, `..`, the `'unknown'` sentinel that sanitizeRepoName
|
|
22
|
+
* emits for un-nameable inputs, names beginning with `.` (which would collide
|
|
23
|
+
* with the `.staging-` prefix), and anything outside the safe charset.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getUploadDir(repoName: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Derive a filesystem-safe upload directory name from the manifest's
|
|
28
|
+
* top-level folder. Returns null when the name is un-nameable (so the caller
|
|
29
|
+
* rejects with 400 rather than colliding everyone on `UPLOAD_ROOT/unknown`).
|
|
30
|
+
*/
|
|
31
|
+
export declare function deriveUploadName(topLevelName: string): string | null;
|