Skip to content

Commit d9f38a9

Browse files
authored
feat(core): allow validation plugins to create new files in cloud assembly (#38007)
## Summary - Changes the validation plugin integrity check from fingerprinting the entire `outdir` to snapshotting only pre-existing file hashes before plugins run. - Plugins may now **create new files** in the cloud assembly directory (e.g. SARIF reports, custom output files) without triggering the "modified the cloud assembly" error. - **Modifications** or **deletions** of pre-existing files are still caught and throw. - Replaces `FileSystem.fingerprint` with per-file SHA-256 hashes because `fingerprint` computes a single hash over the entire directory tree, making it impossible to distinguish new files from modifications to existing ones. The plugin contract is updated from "plugins cannot modify the cloud assembly" to "plugins cannot modify or delete files that existed in the cloud assembly prior to plugin execution." ## Test plan - [x] Existing test: `plugin tries to modify a template` — still throws (modification of pre-existing file) - [x] New test: `plugin that writes new files to assembly is allowed` — creates a file, no error, file contents verified - [x] New test: `plugin that deletes pre-existing file is caught` — deletion detected and throws - [x] Full validation test suite passes (50/50)
1 parent 109fae7 commit d9f38a9

2 files changed

Lines changed: 87 additions & 6 deletions

File tree

packages/aws-cdk-lib/core/lib/private/synthesis.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as crypto from 'crypto';
12
import * as fs from 'fs';
23
import * as path from 'path';
34
import * as private_cxapi from '@aws-cdk/cloud-assembly-api';
@@ -17,7 +18,6 @@ import { App } from '../app';
1718
import { _aspectTreeRevisionReader, AspectApplication, AspectPriority, Aspects } from '../aspect';
1819
import { AssumptionError, UnscopedValidationError } from '../errors';
1920
import { FeatureFlags } from '../feature-flags';
20-
import { FileSystem } from '../fs';
2121
import { Stack } from '../stack';
2222
import type { ISynthesisSession } from '../stack-synthesizers/types';
2323
import type { StageSynthesisOptions } from '../stage';
@@ -105,7 +105,6 @@ function getAssemblies(root: App, rootAssembly: private_cxapi.CloudAssembly): Ma
105105
*/
106106
function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: private_cxapi.CloudAssembly) {
107107
if (!App.isApp(root)) return;
108-
let hash: string | undefined;
109108
const assemblies = getAssemblies(root, assembly);
110109
const templatePathsByPlugin: Map<IPolicyValidationPlugin, string[]> = new Map();
111110
visitAssemblies(root, 'post', construct => {
@@ -142,9 +141,9 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
142141
// eslint-disable-next-line no-console
143142
console.error('Performing Policy Validations\n');
144143

145-
if (templatePathsByPlugin.size > 0) {
146-
hash = FileSystem.fingerprint(outdir);
147-
}
144+
// Snapshot pre-existing files so we can detect modifications while still
145+
// allowing plugins to create new files in the assembly directory.
146+
const preExistingFileHashes = snapshotFileHashes(outdir);
148147

149148
// Run all plugins through the same loop
150149
const reports: NamedValidationPluginReport[] = [];
@@ -163,7 +162,7 @@ function invokeValidationPlugins(root: IConstruct, outdir: string, assembly: pri
163162
},
164163
});
165164
}
166-
if (hash && FileSystem.fingerprint(outdir) !== hash) {
165+
if (hasModifiedPreExistingFiles(preExistingFileHashes)) {
167166
throw new AssumptionError(lit`IllegalOperationValidationPlugin`, `Illegal operation: validation plugin '${plugin.name}' modified the cloud assembly`);
168167
}
169168
}
@@ -565,3 +564,41 @@ function getBooleanContext(root: IConstruct, key: string, defaultValue: boolean)
565564
if (raw === undefined) return defaultValue;
566565
return raw !== false && raw !== 'false';
567566
}
567+
568+
function collectFilePaths(dir: string): string[] {
569+
const results: string[] = [];
570+
function walk(current: string) {
571+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
572+
const full = path.join(current, entry.name);
573+
if (entry.isDirectory()) {
574+
walk(full);
575+
} else {
576+
results.push(full);
577+
}
578+
}
579+
}
580+
walk(dir);
581+
return results;
582+
}
583+
584+
function hashFile(filePath: string): string {
585+
const content = fs.readFileSync(filePath);
586+
return crypto.createHash('sha256').update(content).digest('hex');
587+
}
588+
589+
function snapshotFileHashes(dir: string): Map<string, string> {
590+
const hashes = new Map<string, string>();
591+
for (const filePath of collectFilePaths(dir)) {
592+
hashes.set(filePath, hashFile(filePath));
593+
}
594+
return hashes;
595+
}
596+
597+
function hasModifiedPreExistingFiles(snapshot: Map<string, string>): boolean {
598+
for (const [filePath, originalHash] of snapshot) {
599+
if (!fs.existsSync(filePath) || hashFile(filePath) !== originalHash) {
600+
return true;
601+
}
602+
}
603+
return false;
604+
}

packages/aws-cdk-lib/core/test/validation/validation.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,50 @@ Policy Validation Report Summary
609609
}).toThrow(/Illegal operation: validation plugin 'rogue-plugin' modified the cloud assembly/);
610610
});
611611

612+
test('plugin that writes new files to assembly is allowed', () => {
613+
const app = new core.App({
614+
policyValidationBeta1: [
615+
{
616+
name: 'file-writer-plugin',
617+
validate(context: core.IPolicyValidationContextBeta1) {
618+
const assemblyDir = path.dirname(context.templatePaths[0]);
619+
fs.writeFileSync(path.join(assemblyDir, 'plugin-output.json'), '{"result":"ok"}');
620+
return { success: true, violations: [] };
621+
},
622+
},
623+
],
624+
});
625+
const stack = new core.Stack(app);
626+
new core.CfnResource(stack, 'DefaultResource', {
627+
type: 'Test::Resource::Fake',
628+
properties: { result: 'success' },
629+
});
630+
expect(() => app.synth()).not.toThrow();
631+
const outputFile = path.join(app.outdir, 'plugin-output.json');
632+
expect(fs.existsSync(outputFile)).toBe(true);
633+
expect(JSON.parse(fs.readFileSync(outputFile, 'utf-8'))).toEqual({ result: 'ok' });
634+
});
635+
636+
test('plugin that deletes pre-existing file is caught', () => {
637+
const app = new core.App({
638+
policyValidationBeta1: [
639+
{
640+
name: 'deleter-plugin',
641+
validate(context: core.IPolicyValidationContextBeta1) {
642+
fs.unlinkSync(context.templatePaths[0]);
643+
return { success: true, violations: [] };
644+
},
645+
},
646+
],
647+
});
648+
const stack = new core.Stack(app);
649+
new core.CfnResource(stack, 'DefaultResource', {
650+
type: 'Test::Resource::Fake',
651+
properties: { result: 'success' },
652+
});
653+
expect(() => app.synth()).toThrow(/Illegal operation: validation plugin 'deleter-plugin' modified the cloud assembly/);
654+
});
655+
612656
test('failSynthOnValidationErrors=false writes JSON but does not print or fail', () => {
613657
const app = new core.App({
614658
policyValidationBeta1: [

0 commit comments

Comments
 (0)