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:
Gergo Magyar 2026-05-29 20:36:40 +00:00
parent 2f7ef91542
commit 72173125ea
2 changed files with 121 additions and 30 deletions

View file

@ -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> {

View file

@ -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 });
}
});
});