Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/core/src/config/projectRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@
);

expect(id1).toBe('gemini');
expect(id2).toBe('gemini-1');

Check warning on line 76 in packages/core/src/config/projectRegistry.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-1". Please make sure this change is appropriate to submit.
expect(id3).toBe('gemini-2');

Check warning on line 77 in packages/core/src/config/projectRegistry.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-2". Please make sure this change is appropriate to submit.
});

it('persists and reloads the registry', async () => {
Expand Down Expand Up @@ -157,9 +157,9 @@
const projectZ = normalizePath(path.join(tempDir, 'gemini'));
const shortId = await registry.getShortId(projectZ);

// 3. It should avoid 'gemini' and pick 'gemini-1' (or similar)

Check warning on line 160 in packages/core/src/config/projectRegistry.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-1". Please make sure this change is appropriate to submit.
expect(shortId).not.toBe('gemini');
expect(shortId).toBe('gemini-1');

Check warning on line 162 in packages/core/src/config/projectRegistry.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Found sensitive keyword "gemini-1". Please make sure this change is appropriate to submit.
});

it('invalidates registry mapping if disk ownership changed', async () => {
Expand Down Expand Up @@ -374,4 +374,65 @@

readFileSpy.mockRestore();
});

it('recovers gracefully if registry is an empty object (invalid schema)', async () => {
// 1. Write an empty object which is valid JSON but invalid schema
fs.writeFileSync(registryPath, '{}');

const registry = new ProjectRegistry(registryPath);
await registry.initialize();

// 2. It should not crash and should allow adding new projects
const projectPath = path.join(tempDir, 'my-project');
const shortId = await registry.getShortId(projectPath);

expect(shortId).toBe('my-project');

// 3. Verify it healed the file
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(data.projects).toBeDefined();
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
});

it('recovers gracefully if registry projects property is an array (invalid schema)', async () => {
// 1. Write an object where 'projects' is an array
fs.writeFileSync(registryPath, JSON.stringify({ projects: [] }));

const registry = new ProjectRegistry(registryPath);
await registry.initialize();

// 2. It should reset and allow adding new projects correctly
const projectPath = path.join(tempDir, 'my-project');
const shortId = await registry.getShortId(projectPath);

expect(shortId).toBe('my-project');

// 3. Verify it healed the file to an object
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(data.projects).toBeDefined();
expect(Array.isArray(data.projects)).toBe(false);
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
});

it('recovers gracefully if registry contains malicious slugs (path traversal)', async () => {
// 1. Write a registry with a path traversal slug
fs.writeFileSync(
registryPath,
JSON.stringify({ projects: { '/some/path': '../../etc/passwd' } }),
);

const registry = new ProjectRegistry(registryPath);
await registry.initialize();

// 2. It should identify as invalid and reset
const projectPath = path.join(tempDir, 'my-project');
const shortId = await registry.getShortId(projectPath);

expect(shortId).toBe('my-project');

// 3. Verify it healed the file and didn't preserve the malicious entry
const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(data.projects[normalizePath(projectPath)]).toBe('my-project');
expect(Object.values(data.projects)).not.toContain('../../etc/passwd');
});
});
21 changes: 19 additions & 2 deletions packages/core/src/config/projectRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { lock } from 'proper-lockfile';
import { z } from 'zod';
import { debugLogger } from '../utils/debugLogger.js';
import { isNodeError } from '../utils/errors.js';

export interface RegistryData {
projects: Record<string, string>;
}

const registryDataSchema = z.object({
projects: z.record(z.string(), z.string().regex(/^[a-z0-9-]+$/)),
});

const PROJECT_ROOT_FILE = '.project_root';
const LOCK_TIMEOUT_MS = 10000;
const LOCK_RETRY_DELAY_MS = 100;
Expand Down Expand Up @@ -57,8 +62,16 @@ export class ProjectRegistry {
private async loadData(): Promise<RegistryData> {
try {
const content = await fs.promises.readFile(this.registryPath, 'utf8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(content);
const parsed: unknown = JSON.parse(content);

if (this.isValidRegistryData(parsed)) {
return parsed;
}

debugLogger.warn(
`Project registry at ${this.registryPath} has an invalid schema, resetting to empty.`,
);
return { projects: {} };
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
return { projects: {} }; // Normal first run
Expand Down Expand Up @@ -407,4 +420,8 @@ export class ProjectRegistry {
.replace(/^-|-$/g, '') || 'project'
);
}

private isValidRegistryData(data: unknown): data is RegistryData {
return registryDataSchema.safeParse(data).success;
}
}
Loading