GitNexus/gitnexus/src/server/upload-paths.ts
glier 16e6ad6da8
Some checks are pending
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(server): resolve clone/upload/mapping roots from GITNEXUS_HOME (#2229)
* fix(server): resolve clone/upload/mapping roots from GITNEXUS_HOME

CLONE_ROOT (git-clone.ts), UPLOAD_ROOT (upload-paths.ts), and the
server-mapping file (embeddings/server-mapping.ts) computed their root
from os.homedir() directly, ignoring GITNEXUS_HOME. The Docker image
sets GITNEXUS_HOME=/data/gitnexus (the persistent volume that also holds
the registry and indexes), so cloned/uploaded repos and their .gitnexus
indexes instead landed in the container's ephemeral ~/.gitnexus and were
lost on container recreation, while registry.json (which honors
GITNEXUS_HOME) kept pointing at the now-dead path. That also defeated
incremental re-analysis: a recreated container re-clones from scratch and
full-rebuilds instead of git pull + incremental update.

Source all three from the existing getGlobalDir() helper — the same
GITNEXUS_HOME-aware primitive the registry and groups already use.
Behavior is unchanged when GITNEXUS_HOME is unset (CLI / local installs):
it falls back to ~/.gitnexus exactly as before. No signatures change and
no UPLOAD_ROOT consumers are touched.

Adds test/unit/gitnexus-home-roots.test.ts covering both the
GITNEXUS_HOME-set and unset paths for all three roots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): make clone-root assertions GITNEXUS_HOME-aware; cover server-mapping fallback

Addresses PR review (#2229):

- git-clone.test.ts hardcoded path.join(os.homedir(), '.gitnexus', 'repos')
  for the clone root, so the "direct child of the clone root" and containment
  assertions failed when GITNEXUS_HOME was set in the ambient env (e.g. a CI
  runner). CLONE_ROOT now derives from getGlobalDir(); mirror that derivation
  via EXPECTED_CLONE_ROOT (GITNEXUS_HOME || ~/.gitnexus), computed at module
  load — the same point CLONE_ROOT is frozen — so the two always agree.

- The GITNEXUS_HOME-unset fallback test covered clone + upload roots but not
  server-mapping (one of the three changed modules). Add a fallback case for
  readServerMapping. It redirects HOME/USERPROFILE to a tmp dir (os.homedir()
  honors them) so it exercises the real ~/.gitnexus fallback without writing
  into the developer's actual ~/.gitnexus/server-mapping.json.

Both files pass with and without GITNEXUS_HOME set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): use mkdtemp for GITNEXUS_HOME path-root test temp dirs

CodeQL js/insecure-temporary-file flagged the two writes that used a fixed
os.tmpdir() name (gitnexus-home-roots-test, gitnexus-fallback-home): a
co-located process could pre-create or symlink the predictable path before
the test write lands. Switch both to fs.mkdtemp(), which atomically creates a
uniquely-named directory — the canonical sanitizer this repo already uses
(see core/group/storage.ts). Behavior is unchanged; tests still pass with and
without GITNEXUS_HOME set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(embeddings): resolve server-mapping path for parity with clone/upload roots

MAPPING_FILE now wraps path.resolve() like CLONE_ROOT (git-clone.ts) and
UPLOAD_ROOT (upload-paths.ts), so a relative GITNEXUS_HOME yields an absolute
path. No-op for the supported absolute-GITNEXUS_HOME config (Docker) and for
readServerMapping's only caller (run-analyze.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): derive EXPECTED_CLONE_ROOT from getGlobalDir() to avoid drift

The test mirror now imports getGlobalDir() and computes the expected clone root
the same way production CLONE_ROOT does, instead of re-deriving the
GITNEXUS_HOME || ~/.gitnexus fallback by hand. Byte-identical today; future-proof
if getGlobalDir() grows a branch. (os import retained — still used by os.tmpdir().)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): rename shadowed savedHome in server-mapping fallback test

The inner savedHome (saves process.env.HOME) shadowed the describe-scope
savedHome (saves process.env.GITNEXUS_HOME). Renamed the inner one to
savedProcessHome at its declaration and both restore sites in the finally
block. Pure rename, no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): scope customHome temp dir to the GITNEXUS_HOME-set cases

beforeEach created a customHome temp dir for all five tests, but the two
fallback cases never use it (one manages its own fakeHome, the other needs
none). Moved the mkdtemp/rm into a nested describe('with GITNEXUS_HOME set')
wrapping the three set-cases; the shared outer afterEach still restores
GITNEXUS_HOME and resets modules for all five.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-18 17:39:44 +01:00

64 lines
2.4 KiB
TypeScript

/**
* Upload working-directory paths.
*
* 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
* an mkdtemp dir *under* UPLOAD_ROOT so the promote rename stays on one
* filesystem and remains atomic (a rename from os.tmpdir() could trip EXDEV —
* the exact Docker case this feature targets; see bridge-db.ts for the same
* anchored-staging pattern).
*/
import path from 'path';
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.
*
* 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-';
/**
* Get the upload target directory for a repo name.
*
* Re-validates at the boundary (callers may derive the name from an untrusted
* manifest). Rejects `.`, `..`, the `'unknown'` sentinel that sanitizeRepoName
* emits for un-nameable inputs, names beginning with `.` (which would collide
* with the `.staging-` prefix), and anything outside the safe charset.
*/
export function getUploadDir(repoName: string): string {
if (
!repoName ||
repoName === '.' ||
repoName === '..' ||
repoName === 'unknown' ||
repoName.startsWith('.') ||
!REPO_NAME_PATTERN.test(repoName)
) {
throw new Error('Invalid repository name');
}
return path.join(UPLOAD_ROOT, repoName);
}
/**
* Derive a filesystem-safe upload directory name from the manifest's
* top-level folder. Returns null when the name is un-nameable (so the caller
* rejects with 400 rather than colliding everyone on `UPLOAD_ROOT/unknown`).
*/
export function deriveUploadName(topLevelName: string): string | null {
const safe = sanitizeRepoName(topLevelName);
if (safe === 'unknown' || safe === '.' || safe === '..' || safe.startsWith('.')) {
return null;
}
return safe;
}