mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
fix(csharp): cap per-file size in namespace scan; fail open on skip (#1881)
scanCSharpProject read every .cs/.csproj in full with no size guard and issued per-directory reads with no concurrency bound, an OOM/FD-exhaustion vector on large or generated repos. Add an fs.stat size guard before each read, reusing getMaxFileSizeBytes() (the same 512KB cap the Phase-1 walker uses). An oversized or unreadable .cs now signals truncation so the #1881 suffix-fallback gate fails OPEN rather than wrongly suppressing an import whose declaring namespace lived in the skipped file (previously a silent return left the scan looking complete). Adds a size-cap scan test.
This commit is contained in:
parent
2f7ef91542
commit
72173125ea
2 changed files with 121 additions and 30 deletions
|
|
@ -4,6 +4,7 @@ import type { ImportConfigs } from './import-resolvers/types.js';
|
|||
import type { CsharpFileStructure } from './languages/csharp/namespace-siblings.js';
|
||||
|
||||
import { isDev } from './utils/env.js';
|
||||
import { getMaxFileSizeBytes } from './utils/max-file-size.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
// ============================================================================
|
||||
|
|
@ -227,6 +228,10 @@ export async function scanCSharpProject(repoRoot: string): Promise<CSharpProject
|
|||
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
let dirsScanned = 0;
|
||||
let truncated = false;
|
||||
// Per-file read cap (shared with the Phase-1 walker). A `.cs`/`.csproj` larger
|
||||
// than this is skipped unread so a single huge generated file can't pull an
|
||||
// unbounded buffer into memory during the always-on scan.
|
||||
const maxFileSizeBytes = getMaxFileSizeBytes();
|
||||
|
||||
while (scanQueue.length > 0) {
|
||||
if (dirsScanned >= CSHARP_SCAN_MAX_DIRS) {
|
||||
|
|
@ -250,7 +255,7 @@ export async function scanCSharpProject(repoRoot: string): Promise<CSharpProject
|
|||
// in entry order (config precedence matters) while `.cs` namespace results
|
||||
// land in shared Sets where order is irrelevant.
|
||||
const csprojReads: Promise<CSharpProjectConfig | null>[] = [];
|
||||
const csReads: Promise<void>[] = [];
|
||||
const csReads: Promise<boolean>[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (CSHARP_SCAN_SKIP_DIRS.has(entry.name)) continue;
|
||||
|
|
@ -264,9 +269,11 @@ export async function scanCSharpProject(repoRoot: string): Promise<CSharpProject
|
|||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(dir, entry.name);
|
||||
if (entry.name.endsWith('.csproj')) {
|
||||
csprojReads.push(readCsprojConfig(filePath, entry.name, repoRoot, dir));
|
||||
csprojReads.push(readCsprojConfig(filePath, entry.name, repoRoot, dir, maxFileSizeBytes));
|
||||
} else if (entry.name.endsWith('.cs')) {
|
||||
csReads.push(collectDeclaredNamespaces(filePath, declaredNamespaces, rootNamespaces));
|
||||
csReads.push(
|
||||
collectDeclaredNamespaces(filePath, declaredNamespaces, rootNamespaces, maxFileSizeBytes),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const config of await Promise.all(csprojReads)) {
|
||||
|
|
@ -275,7 +282,12 @@ export async function scanCSharpProject(repoRoot: string): Promise<CSharpProject
|
|||
rootNamespaces.add(config.rootNamespace);
|
||||
}
|
||||
}
|
||||
await Promise.all(csReads);
|
||||
// A `.cs` that was skipped (oversized) or unreadable leaves its namespaces
|
||||
// uncollected, so the scan is incomplete → mark truncated to fail the
|
||||
// #1881 gate OPEN rather than wrongly suppress an import declared there.
|
||||
for (const skipped of await Promise.all(csReads)) {
|
||||
if (skipped) truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
|
|
@ -294,8 +306,11 @@ async function readCsprojConfig(
|
|||
fileName: string,
|
||||
repoRoot: string,
|
||||
dir: string,
|
||||
maxFileSizeBytes: number,
|
||||
): Promise<CSharpProjectConfig | null> {
|
||||
try {
|
||||
const stat = await fs.stat(csprojPath);
|
||||
if (stat.size > maxFileSizeBytes) return null; // oversized .csproj → skip unread
|
||||
const content = await fs.readFile(csprojPath, 'utf-8');
|
||||
const nsMatch = content.match(CSHARP_ROOT_NAMESPACE_RE);
|
||||
const rootNamespace = nsMatch ? nsMatch[1].trim() : fileName.replace(/\.csproj$/, '');
|
||||
|
|
@ -311,16 +326,29 @@ async function readCsprojConfig(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect declared `namespace` names from one `.cs` file into the shared Sets.
|
||||
*
|
||||
* Returns `true` when the file was skipped (oversized) or could not be read, so
|
||||
* the caller can mark the scan truncated — its namespaces are missing, and the
|
||||
* #1881 gate must fail OPEN rather than wrongly suppress an import declared in
|
||||
* the unread file. Returns `false` on a successful read.
|
||||
*/
|
||||
async function collectDeclaredNamespaces(
|
||||
filePath: string,
|
||||
declaredNamespaces: Set<string>,
|
||||
rootNamespaces: Set<string>,
|
||||
): Promise<void> {
|
||||
maxFileSizeBytes: number,
|
||||
): Promise<boolean> {
|
||||
let content: string;
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.size > maxFileSizeBytes) {
|
||||
return true; // oversized source → skip unread, signal truncation
|
||||
}
|
||||
content = await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return; // unreadable source
|
||||
return true; // unreadable source → signal truncation (was a silent skip)
|
||||
}
|
||||
const scan = await getCsharpStructureScanner();
|
||||
for (const ns of scan(content).namespaces) {
|
||||
|
|
@ -328,6 +356,7 @@ async function collectDeclaredNamespaces(
|
|||
const dot = ns.indexOf('.');
|
||||
rootNamespaces.add(dot === -1 ? ns : ns.slice(0, dot));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/language
|
|||
import { interpretCsharpImport } from '../../../../src/core/ingestion/languages/csharp/interpret.js';
|
||||
import { resolveCsharpImportTarget } from '../../../../src/core/ingestion/languages/csharp/import-target.js';
|
||||
import { loadCsharpResolutionConfig } from '../../../../src/core/ingestion/languages/csharp/resolution-config.js';
|
||||
import { getMaxFileSizeBytes } from '../../../../src/core/ingestion/utils/max-file-size.js';
|
||||
import {
|
||||
csharpSuffixFallbackAllowed,
|
||||
importAlignsWithDeclaredNamespaces,
|
||||
|
|
@ -218,11 +219,11 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx('Services/OrderService.cs', [
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
'Tasks.cs',
|
||||
'Events/OrderCreatedEvent.cs',
|
||||
], new Set(['MyApp.Services', 'MyApp.Events', 'MyApp.Legacy'])),
|
||||
['Services/OrderService.cs', 'Tasks.cs', 'Events/OrderCreatedEvent.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Events', 'MyApp.Legacy']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
|
@ -236,7 +237,11 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx('Services/UserService.cs', ['Services/UserService.cs', 'Models/User.cs'], new Set(['MyApp.Models', 'MyApp.Services'])),
|
||||
ctx(
|
||||
'Services/UserService.cs',
|
||||
['Services/UserService.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Models', 'MyApp.Services']),
|
||||
),
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
|
|
@ -250,10 +255,15 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx('Services/OrderService.cs', ['Services/OrderService.cs', 'Models/User.cs'], new Set(['MyApp.Services', 'MyApp.Models']), {
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
}),
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Models/User.cs'],
|
||||
new Set(['MyApp.Services', 'MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe('Models/User.cs');
|
||||
});
|
||||
|
|
@ -272,10 +282,15 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx('Services/OrderService.cs', ['Services/OrderService.cs', 'Foo/Bar.cs'], new Set(['MyApp.Models']), {
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
}),
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Foo/Bar.cs'],
|
||||
new Set(['MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
csharpConfigs: [{ rootNamespace: 'MyApp', projectDir: '' }],
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
|
@ -292,15 +307,24 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
};
|
||||
const anchored = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx('Services/OrderService.cs', ['Services/OrderService.cs', 'Core/Thing.cs'], new Set(['MyApp.Core.Models']), {
|
||||
rootNamespaces: new Set(['MyApp.Core']),
|
||||
}),
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Core/Thing.cs'],
|
||||
new Set(['MyApp.Core.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp.Core']),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(anchored).toBe('Core/Thing.cs');
|
||||
|
||||
const unanchored = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx('Services/OrderService.cs', ['Services/OrderService.cs', 'Core/Thing.cs'], new Set(['MyApp.Core.Models'])),
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Core/Thing.cs'],
|
||||
new Set(['MyApp.Core.Models']),
|
||||
),
|
||||
);
|
||||
expect(unanchored).toBe(null);
|
||||
});
|
||||
|
|
@ -317,9 +341,14 @@ describe('resolveCsharpImportTarget — suffix match against .cs files', () => {
|
|||
};
|
||||
const result = resolveCsharpImportTarget(
|
||||
parsed,
|
||||
ctx('Services/OrderService.cs', ['Services/OrderService.cs', 'Other/Thing.cs'], new Set(['MyApp.Models']), {
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
}),
|
||||
ctx(
|
||||
'Services/OrderService.cs',
|
||||
['Services/OrderService.cs', 'Other/Thing.cs'],
|
||||
new Set(['MyApp.Models']),
|
||||
{
|
||||
rootNamespaces: new Set(['MyApp']),
|
||||
},
|
||||
),
|
||||
);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
|
@ -389,7 +418,11 @@ describe('importAlignsWithDeclaredNamespaces — declared-namespace gate (#1881)
|
|||
// open the gate for `using System.Threading.Tasks;`.
|
||||
const declared = new Set(['System.Threading.Tasks.Extensions', 'MyApp.Models']);
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces('System.Threading.Tasks', declared, new Set(['MyApp', 'System'])),
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Threading.Tasks',
|
||||
declared,
|
||||
new Set(['MyApp', 'System']),
|
||||
),
|
||||
).toBe(false);
|
||||
// Same conclusion without explicit roots (top-level segment fallback).
|
||||
expect(importAlignsWithDeclaredNamespaces('System.Threading.Tasks', declared)).toBe(false);
|
||||
|
|
@ -397,7 +430,11 @@ describe('importAlignsWithDeclaredNamespaces — declared-namespace gate (#1881)
|
|||
|
||||
it('returns false for an unrelated BCL namespace', () => {
|
||||
expect(
|
||||
importAlignsWithDeclaredNamespaces('System.Linq', new Set(['MyApp.Services']), new Set(['MyApp'])),
|
||||
importAlignsWithDeclaredNamespaces(
|
||||
'System.Linq',
|
||||
new Set(['MyApp.Services']),
|
||||
new Set(['MyApp']),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -500,7 +537,8 @@ describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () =>
|
|||
|
||||
it('collects file-scoped, block, and multiple-per-file namespaces; skips bin/obj; reads csproj root', async () => {
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj': '<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'Scoped.cs': 'namespace Alpha.Scoped;\npublic class A {}',
|
||||
'Block.cs': 'namespace Beta.Block\n{\n public class B {}\n}',
|
||||
'Multi.cs': 'namespace Gamma.One { }\nnamespace Gamma.Two { }',
|
||||
|
|
@ -536,7 +574,8 @@ describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () =>
|
|||
// whole repo. Proving truncated===false here pins the gate ON for repos
|
||||
// of normal depth.
|
||||
const root = await makeTempRepo({
|
||||
'App.csproj': '<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'App.csproj':
|
||||
'<Project><PropertyGroup><RootNamespace>MyApp</RootNamespace></PropertyGroup></Project>',
|
||||
'a/b/c/d/e/f/g/h/Deep.cs': 'namespace MyApp.Deep.Feature;',
|
||||
});
|
||||
try {
|
||||
|
|
@ -569,4 +608,27 @@ describe('loadCsharpResolutionConfig — one-pass namespace scan (#1881)', () =>
|
|||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips an oversized .cs file and fails open via truncation (#1881)', async () => {
|
||||
// A .cs file larger than the per-file size cap is skipped unread so the
|
||||
// always-on scan can't pull an unbounded buffer into memory. Its namespace
|
||||
// is then missing, so `truncated` trips and the gate fails OPEN rather than
|
||||
// wrongly suppress an import declared there. Sized to the real cap — do NOT
|
||||
// lower the production cap for the test.
|
||||
const cap = getMaxFileSizeBytes();
|
||||
const oversized = `namespace Deep.Ns;\n${'// pad\n'.repeat(Math.ceil(cap / 7) + 1)}`;
|
||||
const root = await makeTempRepo({
|
||||
'Shallow.cs': 'namespace Shallow.Ns;',
|
||||
'Huge.cs': oversized,
|
||||
});
|
||||
try {
|
||||
const config = await loadCsharpResolutionConfig(root);
|
||||
const ns = config.namespaces!;
|
||||
expect(ns.truncated).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Shallow.Ns')).toBe(true);
|
||||
expect(ns.declaredNamespaces!.has('Deep.Ns')).toBe(false);
|
||||
} finally {
|
||||
await fsp.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue