diff --git a/gitnexus/src/core/embeddings/server-mapping.ts b/gitnexus/src/core/embeddings/server-mapping.ts index fa685ac8f..77a7de445 100644 --- a/gitnexus/src/core/embeddings/server-mapping.ts +++ b/gitnexus/src/core/embeddings/server-mapping.ts @@ -1,15 +1,19 @@ /** * Server Mapping Configuration * - * Reads ~/.gitnexus/server-mapping.json to map repo names to service names. + * Reads getGlobalDir()/server-mapping.json to map repo names to service names. * Used in embedding text to enrich metadata with microservice context. */ import fs from 'fs/promises'; import path from 'path'; -import os from 'os'; +import { getGlobalDir } from '../../storage/repo-manager.js'; -const MAPPING_FILE = path.join(os.homedir(), '.gitnexus', 'server-mapping.json'); +// Sourced from getGlobalDir() so it honors GITNEXUS_HOME (the Docker image sets +// GITNEXUS_HOME=/data/gitnexus); falls back to ~/.gitnexus when unset. Wrapped in +// path.resolve() for parity with the clone/upload roots (git-clone.ts CLONE_ROOT, +// upload-paths.ts UPLOAD_ROOT) so a relative GITNEXUS_HOME still yields an absolute path. +const MAPPING_FILE = path.resolve(path.join(getGlobalDir(), 'server-mapping.json')); let cachedMapping: Record | null = null; diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 16a7c48c5..477f47a6e 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -1,20 +1,29 @@ /** * Git Clone Utility * - * Shallow-clones repositories into ~/.gitnexus/repos/{name}/. + * Shallow-clones repositories into the clone root (getGlobalDir()/repos/{name}/). * If already cloned, does git pull instead. */ import { spawn } from 'child_process'; import path from 'path'; -import os from 'os'; import fs from 'fs/promises'; import { isIP } from 'net'; import { logger } from '../core/logger.js'; import { parseRepoNameFromUrl } from '../storage/git.js'; +import { getGlobalDir } from '../storage/repo-manager.js'; -/** Root directory for all cloned repositories. Targets must resolve inside this. */ -const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); +/** + * Root directory for all cloned repositories. Targets must resolve inside this. + * + * Sourced from getGlobalDir() so it honors GITNEXUS_HOME — the Docker image sets + * GITNEXUS_HOME=/data/gitnexus, the persistent volume that also holds the + * registry and indexes. Without this, clones landed in the container's + * ephemeral ~/.gitnexus/repos and were lost on container recreation while the + * registry still pointed at the dead path. Falls back to ~/.gitnexus when the + * env var is unset (CLI / local installs), matching the prior behavior exactly. + */ +const CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos')); // A valid git repository name is filesystem-safe: alphanumerics plus `. _ -`. // Rejecting anything else (including `..`, `/`, `\`, shell metacharacters) diff --git a/gitnexus/src/server/upload-paths.ts b/gitnexus/src/server/upload-paths.ts index ef957c85b..80fd6dd44 100644 --- a/gitnexus/src/server/upload-paths.ts +++ b/gitnexus/src/server/upload-paths.ts @@ -1,7 +1,7 @@ /** * Upload working-directory paths. * - * Browser folder uploads are written into ~/.gitnexus/uploads/{name}/ — a + * Browser folder uploads are written into getGlobalDir()/uploads/{name}/ — a * sibling of the clone root (git-clone.ts CLONE_ROOT) — so an uploaded repo * persists and behaves like a cloned one (the graph UI's /api/file reads its * files after analysis, and DELETE /api/repo removes it). Staging happens in @@ -12,12 +12,18 @@ */ import path from 'path'; -import os from 'os'; import { sanitizeRepoName } from '../storage/git.js'; import { REPO_NAME_PATTERN } from './git-clone.js'; +import { getGlobalDir } from '../storage/repo-manager.js'; -/** Root directory for all uploaded repositories. Targets must resolve inside this. */ -export const UPLOAD_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'uploads')); +/** + * Root directory for all uploaded repositories. Targets must resolve inside this. + * + * Sourced from getGlobalDir() so it honors GITNEXUS_HOME and stays a sibling of + * the clone root on the same (in Docker, persistent) volume. Falls back to + * ~/.gitnexus when the env var is unset. + */ +export const UPLOAD_ROOT = path.resolve(path.join(getGlobalDir(), 'uploads')); /** Prefix for per-upload staging directories created under UPLOAD_ROOT. */ export const STAGING_PREFIX = '.staging-'; diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 020bc0d88..4db09c837 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -31,6 +31,16 @@ import os from 'node:os'; import fs from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { getRemoteOriginUrl } from '../../src/storage/git.js'; +import { getGlobalDir } from '../../src/storage/repo-manager.js'; + +// CLONE_ROOT now derives from getGlobalDir() (GITNEXUS_HOME || ~/.gitnexus), so +// assertions must mirror that derivation rather than hardcoding ~/.gitnexus — +// otherwise an ambient GITNEXUS_HOME (e.g. a CI runner that sets it) makes the +// "direct child of the clone root" assertions fail. We call getGlobalDir() +// directly (the same function production CLONE_ROOT uses) so the test cannot +// drift from production if that derivation ever changes. Computed at module +// load, the same point CLONE_ROOT is frozen, so the two always agree. +const EXPECTED_CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos')); describe('git-clone', () => { describe('extractRepoName', () => { @@ -112,11 +122,9 @@ describe('git-clone', () => { }); describe('getCloneDir', () => { - it('returns path under ~/.gitnexus/repos/', () => { + it('returns path under the clone root (getGlobalDir()/repos/)', () => { const dir = getCloneDir('my-repo'); - expect(dir).toContain('.gitnexus'); - expect(dir).toMatch(/repos/); - expect(dir).toContain('my-repo'); + expect(dir).toBe(path.join(EXPECTED_CLONE_ROOT, 'my-repo')); }); it('rejects ".." to prevent path-traversal escape from the clone root', () => { @@ -131,7 +139,7 @@ describe('git-clone', () => { }); it('returned path is always a direct child of the clone root', () => { - const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); + const cloneRoot = EXPECTED_CLONE_ROOT; const dir = getCloneDir('my-repo'); const rel = path.relative(cloneRoot, path.resolve(dir)); // path.relative from the parent to the child must be just the child name — @@ -476,7 +484,7 @@ describe('git-clone', () => { // // These tests do NOT mock spawn — the barrier throws synchronously // before git is invoked, so the rejection is observable directly. - const cloneRoot = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); + const cloneRoot = EXPECTED_CLONE_ROOT; it('rejects an absolute target outside CLONE_ROOT', async () => { await expect(cloneOrPull('https://github.com/a/b.git', '/etc/passwd')).rejects.toThrow( diff --git a/gitnexus/test/unit/gitnexus-home-roots.test.ts b/gitnexus/test/unit/gitnexus-home-roots.test.ts new file mode 100644 index 000000000..f50f2a7a6 --- /dev/null +++ b/gitnexus/test/unit/gitnexus-home-roots.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs/promises'; + +/** + * Regression guard: the clone root (git-clone.ts), upload root (upload-paths.ts), + * and server-mapping file (embeddings/server-mapping.ts) must follow + * GITNEXUS_HOME, not the bare home directory. + * + * The Docker image sets GITNEXUS_HOME=/data/gitnexus — the persistent volume + * that also holds the registry and indexes. Before this fix these three roots + * used os.homedir() directly, so clones/uploads landed in the container's + * ephemeral ~/.gitnexus and were lost on container recreation while the + * registry (which honors GITNEXUS_HOME) still pointed at the dead path. + * + * The roots are module-level constants resolved at import time from + * getGlobalDir(), so each case sets the env var, resets the module registry, + * and re-imports under the new value. + */ +describe('GITNEXUS_HOME path roots', () => { + const savedHome = process.env.GITNEXUS_HOME; + + afterEach(() => { + if (savedHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedHome; + vi.resetModules(); + }); + + // customHome is only needed by the GITNEXUS_HOME-set cases below; the two + // fallback cases manage their own (or no) temp dir, so its lifecycle lives in + // this nested block rather than a shared beforeEach. + describe('with GITNEXUS_HOME set', () => { + // mkdtemp (not a fixed os.tmpdir() name) so a co-located process cannot + // pre-create or symlink the path before our writes land + // (CodeQL js/insecure-temporary-file). + let customHome: string; + + beforeEach(async () => { + customHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-home-roots-')); + }); + + afterEach(async () => { + await fs.rm(customHome, { recursive: true, force: true }); + }); + + it('clone root follows GITNEXUS_HOME', async () => { + process.env.GITNEXUS_HOME = customHome; + vi.resetModules(); + const { getCloneDir } = await import('../../src/server/git-clone.js'); + expect(getCloneDir('my-repo')).toBe(path.resolve(customHome, 'repos', 'my-repo')); + }); + + it('upload root follows GITNEXUS_HOME', async () => { + process.env.GITNEXUS_HOME = customHome; + vi.resetModules(); + const { UPLOAD_ROOT, getUploadDir } = await import('../../src/server/upload-paths.js'); + expect(UPLOAD_ROOT).toBe(path.resolve(customHome, 'uploads')); + expect(getUploadDir('my-repo')).toBe(path.resolve(customHome, 'uploads', 'my-repo')); + }); + + it('server-mapping file is read from GITNEXUS_HOME', async () => { + process.env.GITNEXUS_HOME = customHome; + await fs.writeFile( + path.join(customHome, 'server-mapping.json'), + JSON.stringify({ 'my-repo': 'payments-service' }), + 'utf-8', + ); + vi.resetModules(); + const { readServerMapping } = await import('../../src/core/embeddings/server-mapping.js'); + expect(await readServerMapping('my-repo')).toBe('payments-service'); + }); + }); + + it('falls back to ~/.gitnexus when GITNEXUS_HOME is unset', async () => { + delete process.env.GITNEXUS_HOME; + vi.resetModules(); + const { getCloneDir } = await import('../../src/server/git-clone.js'); + const { UPLOAD_ROOT } = await import('../../src/server/upload-paths.js'); + expect(getCloneDir('my-repo')).toBe( + path.resolve(os.homedir(), '.gitnexus', 'repos', 'my-repo'), + ); + expect(UPLOAD_ROOT).toBe(path.resolve(os.homedir(), '.gitnexus', 'uploads')); + }); + + it('server-mapping falls back to ~/.gitnexus when GITNEXUS_HOME is unset', async () => { + // os.homedir() honors $HOME (and $USERPROFILE on Windows), so redirect the + // home dir to a tmp path to exercise the real fallback (getGlobalDir() -> + // ~/.gitnexus) without writing into the developer's actual + // ~/.gitnexus/server-mapping.json. + const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-fallback-home-')); + const savedProcessHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + delete process.env.GITNEXUS_HOME; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + try { + await fs.mkdir(path.join(fakeHome, '.gitnexus'), { recursive: true }); + await fs.writeFile( + path.join(fakeHome, '.gitnexus', 'server-mapping.json'), + JSON.stringify({ 'my-repo': 'fallback-service' }), + 'utf-8', + ); + vi.resetModules(); + const { readServerMapping } = await import('../../src/core/embeddings/server-mapping.js'); + expect(await readServerMapping('my-repo')).toBe('fallback-service'); + } finally { + if (savedProcessHome === undefined) delete process.env.HOME; + else process.env.HOME = savedProcessHome; + if (savedUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = savedUserProfile; + await fs.rm(fakeHome, { recursive: true, force: true }); + } + }); +});