fix(core): handle malformed projects.json in ProjectRegistry (#26885)

This commit is contained in:
Coco Sheng
2026-05-11 16:19:01 -04:00
committed by GitHub
parent f8198a25d8
commit 1340c96071
2 changed files with 80 additions and 2 deletions
@@ -374,4 +374,65 @@ describe('ProjectRegistry', () => {
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');
});
});
+19 -2
View File
@@ -9,6 +9,7 @@ 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';
@@ -16,6 +17,10 @@ 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;
@@ -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
@@ -407,4 +420,8 @@ export class ProjectRegistry {
.replace(/^-|-$/g, '') || 'project'
);
}
private isValidRegistryData(data: unknown): data is RegistryData {
return registryDataSchema.safeParse(data).success;
}
}