fix: isolate cloned repos by owner slug

This commit is contained in:
guyua9 2026-04-29 01:35:30 +08:00
parent dafda284bc
commit 45ea3b39f7
3 changed files with 84 additions and 6 deletions

View file

@ -33,7 +33,7 @@ import { mountMCPEndpoints } from './mcp-http.js';
import { fork } from 'child_process';
import { fileURLToPath, pathToFileURL } from 'url';
import { JobManager } from './analyze-job.js';
import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
import { extractRepoName, extractRepoSlug, getCloneDir, cloneOrPull } from './git-clone.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
@ -776,7 +776,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {});
// 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/
const cloneDir = getCloneDir(entry.name);
const cloneDir = getCloneDir(
entry.remoteUrl ? extractRepoSlug(entry.remoteUrl) : entry.name,
);
try {
const stat = await fs.stat(cloneDir);
if (stat.isDirectory()) {
@ -1291,7 +1293,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Clone if URL provided
if (repoUrl && !repoLocalPath) {
const repoName = extractRepoName(repoUrl);
targetPath = getCloneDir(repoName);
const repoSlug = extractRepoSlug(repoUrl);
targetPath = getCloneDir(repoSlug);
jobManager.updateJob(job.id, {
status: 'cloning',

View file

@ -15,10 +15,51 @@ import { isIP } from 'net';
export function extractRepoName(url: string): string {
const cleaned = url.replace(/\/+$/, '');
const lastSegment = cleaned.split(/[/:]/).pop() || 'unknown';
return lastSegment.replace(/\.git$/, '');
return lastSegment.replace(/\.git$/i, '');
}
/** Get the clone target directory for a repo name. */
const sanitizeClonePathSegment = (segment: string): string => {
const sanitized = segment.replace(/[^a-zA-Z0-9._-]/g, '-');
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'unknown';
};
/**
* Extract an owner-qualified clone slug from an HTTP(S) git URL.
*
* The server's clone cache must not be keyed only by the repository basename:
* different owners can legitimately publish repositories with the same name.
* Include the host and path components so those repositories do not silently
* reuse each other's cached clone directory.
*/
export function extractRepoSlug(url: string): string {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return sanitizeClonePathSegment(extractRepoName(url));
}
const rawSegments = parsed.pathname.split('/').filter(Boolean);
if (rawSegments.length === 0) {
return sanitizeClonePathSegment(extractRepoName(url));
}
rawSegments[rawSegments.length - 1] = rawSegments[rawSegments.length - 1].replace(
/\.git$/i,
'',
);
const segments = [parsed.hostname.toLowerCase(), ...rawSegments].map((segment) => {
try {
return sanitizeClonePathSegment(decodeURIComponent(segment));
} catch {
return sanitizeClonePathSegment(segment);
}
});
return segments.join('/');
}
/** Get the clone target directory for a repo name or owner-qualified slug. */
export function getCloneDir(repoName: string): string {
return path.join(os.homedir(), '.gitnexus', 'repos', repoName);
}

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { extractRepoName, getCloneDir, validateGitUrl } from '../../src/server/git-clone.js';
import {
extractRepoName,
extractRepoSlug,
getCloneDir,
validateGitUrl,
} from '../../src/server/git-clone.js';
describe('git-clone', () => {
describe('extractRepoName', () => {
@ -24,6 +29,23 @@ describe('git-clone', () => {
});
});
describe('extractRepoSlug', () => {
it('keeps the owner path so same-named repositories do not collide', () => {
expect(extractRepoSlug('https://github.com/owner-a/my-repo.git')).toBe(
'github.com/owner-a/my-repo',
);
expect(extractRepoSlug('https://github.com/owner-b/my-repo.git')).toBe(
'github.com/owner-b/my-repo',
);
});
it('keeps nested group paths for non-GitHub hosts', () => {
expect(extractRepoSlug('https://gitlab.com/group/subgroup/repo.git')).toBe(
'gitlab.com/group/subgroup/repo',
);
});
});
describe('getCloneDir', () => {
it('returns path under ~/.gitnexus/repos/', () => {
const dir = getCloneDir('my-repo');
@ -31,6 +53,18 @@ describe('git-clone', () => {
expect(dir).toMatch(/repos/);
expect(dir).toContain('my-repo');
});
it('returns distinct paths for owner-qualified clone slugs', () => {
const first = getCloneDir(
extractRepoSlug('https://github.com/owner-a/shared.git'),
);
const second = getCloneDir(
extractRepoSlug('https://github.com/owner-b/shared.git'),
);
expect(first).not.toBe(second);
expect(first).toContain('owner-a');
expect(second).toContain('owner-b');
});
});
describe('validateGitUrl', () => {