diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts new file mode 100644 index 000000000..e120f4991 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.test.ts @@ -0,0 +1,150 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { parseAst } from 'rollup/parseAst'; + +import type { ModuleDependency, ParsedModuleRecord } from './module-graph'; +import { ensureProgram } from './type-guards'; +import { walkModuleGraph } from './walk-module-graph'; + +const buildRoot = '/project'; + +function createRecord( + id: string, + staticDependencies: string[] = [], + unsupportedDependencies: ModuleDependency[] = [], +): ParsedModuleRecord { + return { + id, + ast: ensureProgram(parseAst('export const value = true;'), id), + staticDependencies, + unsupportedDependencies, + }; +} + +describe('Backend Functions - module graph walk', () => { + test('Should visit each reachable local module once', () => { + const modules = new Map([ + [ + '/project/src/backend/actions.backend.ts', + createRecord('/project/src/backend/actions.backend.ts', [ + '/project/src/backend/helper.ts', + ]), + ], + [ + '/project/src/backend/helper.ts', + createRecord('/project/src/backend/helper.ts', ['/project/src/backend/shared.ts']), + ], + [ + '/project/src/backend/shared.ts', + createRecord('/project/src/backend/shared.ts', ['/project/src/backend/helper.ts']), + ], + ]); + const visited: string[] = []; + + walkModuleGraph( + '/project/src/backend/actions.backend.ts', + modules, + buildRoot, + (context) => { + visited.push(context.moduleId); + }, + ); + + expect(visited).toEqual([ + '/project/src/backend/actions.backend.ts', + '/project/src/backend/helper.ts', + '/project/src/backend/shared.ts', + ]); + }); + + test('Should skip dependencies outside the app-local parseable graph', () => { + const modules = new Map([ + [ + '/project/src/backend/actions.backend.ts', + createRecord('/project/src/backend/actions.backend.ts', [ + '/project/src/backend/helper.ts', + '/project/node_modules/package/index.js', + '/project/src/backend/data.json', + '/external/helper.ts', + ]), + ], + ['/project/src/backend/helper.ts', createRecord('/project/src/backend/helper.ts')], + ]); + const visited: string[] = []; + + walkModuleGraph( + '/project/src/backend/actions.backend.ts', + modules, + buildRoot, + (context) => { + visited.push(context.moduleId); + }, + ); + + expect(visited).toEqual([ + '/project/src/backend/actions.backend.ts', + '/project/src/backend/helper.ts', + ]); + }); + + test('Should fail closed when the entry record is missing', () => { + expect(() => { + walkModuleGraph( + '/project/src/backend/actions.backend.ts', + new Map(), + buildRoot, + () => {}, + ); + }).toThrow( + 'Unsupported local module graph for /project/src/backend/actions.backend.ts: missing module record for /project/src/backend/actions.backend.ts could hide an action-catalog connectionId.', + ); + }); + + test('Should fail closed when a reachable local dependency was not collected', () => { + const modules = new Map([ + [ + '/project/src/backend/actions.backend.ts', + createRecord('/project/src/backend/actions.backend.ts', [ + '/project/src/backend/helper.ts', + ]), + ], + ]); + + expect(() => { + walkModuleGraph( + '/project/src/backend/actions.backend.ts', + modules, + buildRoot, + () => {}, + ); + }).toThrow( + 'Unsupported local module graph for /project/src/backend/actions.backend.ts: uncollected local import /project/src/backend/helper.ts from /project/src/backend/actions.backend.ts could hide an action-catalog connectionId.', + ); + }); + + test('Should fail closed for unsupported reachable local dependencies', () => { + const modules = new Map([ + [ + '/project/src/backend/actions.backend.ts', + createRecord( + '/project/src/backend/actions.backend.ts', + [], + [{ kind: 'dynamic-import', specifier: './helper' }], + ), + ], + ]); + + expect(() => { + walkModuleGraph( + '/project/src/backend/actions.backend.ts', + modules, + buildRoot, + () => {}, + ); + }).toThrow( + 'Unsupported local module graph for /project/src/backend/actions.backend.ts: dynamic-import ./helper could hide an action-catalog connectionId.', + ); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.ts new file mode 100644 index 000000000..c3f66114a --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/walk-module-graph.ts @@ -0,0 +1,87 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { + type ParsedModuleRecord, + shouldTraverseCollectedModule, + unsupportedModuleGraphDependency, +} from './module-graph'; + +export interface ModuleGraphWalkContext { + entryId: string; + moduleId: string; + record: ParsedModuleRecord; +} + +/** + * Walks every collected app-local module statically reachable from a backend + * entry and applies fail-closed graph validation before following dependency + * edges. + */ +export function walkModuleGraph( + entryId: string, + modules: ReadonlyMap, + buildRoot: string, + visit: (context: ModuleGraphWalkContext) => void, +): void { + // Traverse from the real backend entry, not the virtual wrapper used by + // the backend build. Every backend export in this file receives this same + // conservative file-level allowlist. + const pending = [entryId]; + const visited = new Set(); + + while (pending.length > 0) { + // Process each collected module at most once so local cycles cannot + // loop forever. + const moduleId = pending.shift()!; + if (visited.has(moduleId)) { + continue; + } + visited.add(moduleId); + + // A reachable local module that Rollup did not parse means the + // collected graph is incomplete, so fail closed instead of silently + // omitting a possible connection ID. + const record = modules.get(moduleId); + if (!record) { + throw unsupportedModuleGraphDependency( + entryId, + `missing module record for ${moduleId}`, + ); + } + + visit({ entryId, moduleId, record }); + + // Dynamic local imports and local require calls can hide reachable + // action-catalog calls from static traversal. Treat them as unsupported + // graph shapes for this PR. + for (const dependency of record.unsupportedDependencies) { + throw unsupportedModuleGraphDependency( + entryId, + `${dependency.kind} ${dependency.specifier}`, + ); + } + + // Follow only collected local source modules. Package imports, virtual + // entries, generated files, and files outside buildRoot are ignored by + // design because they are outside the app-local backend graph. + for (const dependencyId of record.staticDependencies) { + if (!shouldTraverseCollectedModule(dependencyId, buildRoot)) { + continue; + } + + // A local dependency can be statically reachable but absent from + // the collector if Rollup did not parse it. Fail closed rather than + // trusting an incomplete allowlist. + if (!modules.has(dependencyId)) { + throw unsupportedModuleGraphDependency( + entryId, + `uncollected local import ${dependencyId} from ${record.id}`, + ); + } + + pending.push(dependencyId); + } + } +}