diff --git a/AGENTS.md b/AGENTS.md index 11ec338b15..c44eb51e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,3 +4,7 @@ When working with this repository follow instructions from CLAUDE.md. - [./CLAUDE.md](./CLAUDE.md) - Before committing, run the formatter on modified files. +- Use null-prototype records for object-shaped dictionaries keyed by user-controlled identifiers. + Keep these dictionaries as records throughout runtime code; only convert them at explicit + serialization boundaries. Use `Map` for internal collections that do not need an object-shaped + API or JSON representation. diff --git a/packages/steps/src/BuildFunction.ts b/packages/steps/src/BuildFunction.ts index b6d64c79ac..715f41cce8 100644 --- a/packages/steps/src/BuildFunction.ts +++ b/packages/steps/src/BuildFunction.ts @@ -138,7 +138,7 @@ export class BuildFunction { const inputs = this.inputProviders?.map(inputProvider => { const input = inputProvider(ctx, buildStepId); - if (input.id in callInputs) { + if (Object.hasOwn(callInputs, input.id)) { input.set(callInputs[input.id]); } return input; diff --git a/packages/steps/src/BuildFunctionGroup.ts b/packages/steps/src/BuildFunctionGroup.ts index 2eb1e9ca3f..d0d1e69660 100644 --- a/packages/steps/src/BuildFunctionGroup.ts +++ b/packages/steps/src/BuildFunctionGroup.ts @@ -4,7 +4,7 @@ import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepInputById, BuildStepInputProvider, - makeBuildStepInputByIdMap, + makeBuildStepInputById, } from './BuildStepInput'; import { BuildConfigError } from './errors'; @@ -46,13 +46,13 @@ export class BuildFunctionGroup { this.createBuildStepsFromFunctionGroupCall = (ctx, { callInputs = {} } = {}) => { const inputs = this.inputProviders?.map(inputProvider => { const input = inputProvider(ctx, id); - if (input.id in callInputs) { + if (Object.hasOwn(callInputs, input.id)) { input.set(callInputs[input.id]); } return input; }); return createBuildStepsFromFunctionGroupCall(ctx, { - inputs: makeBuildStepInputByIdMap(inputs), + inputs: makeBuildStepInputById(inputs), }); }; } diff --git a/packages/steps/src/BuildStep.ts b/packages/steps/src/BuildStep.ts index 9c0dac2e9c..de62b23960 100644 --- a/packages/steps/src/BuildStep.ts +++ b/packages/steps/src/BuildStep.ts @@ -9,12 +9,12 @@ import { BuildRuntimePlatform } from './BuildRuntimePlatform'; import { BuildStepCompositeFunctionScope } from './BuildStepCompositeFunctionScope'; import { BuildStepContext, BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepEnv } from './BuildStepEnv'; -import { BuildStepInput, BuildStepInputById, makeBuildStepInputByIdMap } from './BuildStepInput'; +import { BuildStepInput, BuildStepInputById, makeBuildStepInputById } from './BuildStepInput'; import { BuildStepOutput, BuildStepOutputById, SerializedBuildStepOutput, - makeBuildStepOutputByIdMap, + makeBuildStepOutputById, } from './BuildStepOutput'; import { cleanUpStepTemporaryDirectoriesAsync, @@ -35,6 +35,7 @@ import { BIN_PATH } from './utils/shell/bin'; import { getShellCommandAndArgs } from './utils/shell/command'; import { spawnAsync } from './utils/shell/spawn'; import { interpolateWithInputs, interpolateWithOutputs } from './utils/template'; +import { createEmptyRecord } from './utils/record'; export enum BuildStepStatus { NEW = 'new', @@ -50,6 +51,8 @@ export enum BuildStepLogMarker { END_STEP = 'end-step', } +export type BuildStepFunctionInputs = Record; + export type BuildStepFunction = ( ctx: BuildStepContext, { @@ -57,7 +60,7 @@ export type BuildStepFunction = ( outputs, env, }: { - inputs: { [key: string]: { value: unknown } }; + inputs: BuildStepFunctionInputs; outputs: BuildStepOutputById; env: BuildStepEnv; signal?: AbortSignal; @@ -96,7 +99,7 @@ export class BuildStepOutputAccessor { } public hasOutputParameter(name: string): boolean { - return name in this.outputById; + return Object.hasOwn(this.outputById, name); } public serialize(): SerializedBuildStepOutputAccessor { @@ -113,12 +116,10 @@ export class BuildStepOutputAccessor { public static deserialize( serialized: SerializedBuildStepOutputAccessor ): BuildStepOutputAccessor { - const outputById = Object.fromEntries( - Object.entries(serialized.outputById).map(([key, value]) => [ - key, - BuildStepOutput.deserialize(value), - ]) - ); + const outputById = createEmptyRecord(); + for (const [key, value] of Object.entries(serialized.outputById)) { + outputById[key] = BuildStepOutput.deserialize(value); + } return new BuildStepOutputAccessor( serialized.id, serialized.displayName, @@ -194,14 +195,14 @@ export class BuildStep extends BuildStepOutputAccessor { ) { assert(command !== undefined || fn !== undefined, 'Either command or fn must be defined.'); assert(!(command !== undefined && fn !== undefined), 'Command and fn cannot be both set.'); - const outputById = makeBuildStepOutputByIdMap(outputs); + const outputById = makeBuildStepOutputById(outputs); super(id, displayName, false, outputById); this.id = id; this.displayName = displayName; this.supportedRuntimePlatforms = maybeSupportedRuntimePlatforms; this.inputs = inputs; - this.inputById = makeBuildStepInputByIdMap(inputs); + this.inputById = makeBuildStepInputById(inputs); this.outputById = outputById; this.fn = fn; this.command = command; @@ -351,18 +352,14 @@ export class BuildStep extends BuildStepOutputAccessor { } private evaluateOwnStepInputs(): Record { - return ( - this.inputs?.reduce( - (acc, input) => { - acc[input.id] = input.getValue({ - interpolationContext: this.getInterpolationContext(), - skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, - }); - return acc; - }, - {} as Record - ) ?? {} - ); + const inputsById = createEmptyRecord>(); + for (const input of this.inputs ?? []) { + inputsById[input.id] = input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, + }); + } + return inputsById; } private evaluateIfCondition( @@ -491,17 +488,7 @@ export class BuildStep extends BuildStepOutputAccessor { assert(this.fn, 'Function (fn) must be defined'); await this.fn(this.ctx, { - inputs: Object.fromEntries( - Object.entries(this.inputById).map(([key, input]) => [ - key, - { - value: input.getValue({ - interpolationContext: this.getInterpolationContext(), - skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, - }), - }, - ]) - ), + inputs: this.getFunctionInputs(), outputs: this.outputById, env: this.getScriptEnv(), signal: signal ?? undefined, @@ -510,6 +497,19 @@ export class BuildStep extends BuildStepOutputAccessor { this.ctx.logger.debug(`Script completed successfully`); } + private getFunctionInputs(): BuildStepFunctionInputs { + const functionInputs = createEmptyRecord(); + for (const [key, input] of Object.entries(this.inputById)) { + functionInputs[key] = { + value: input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined, + }), + }; + } + return functionInputs; + } + private interpolateInputsOutputsAndGlobalContextInTemplate( template: string, inputs?: BuildStepInput[] @@ -525,18 +525,15 @@ export class BuildStep extends BuildStepOutputAccessor { this.getLegacyStepOutputValue(path) ); } - const vars = inputs.reduce( - (acc, input) => { - const value = input.getValue({ - interpolationContext: this.getInterpolationContext(), - skipLegacyOutputInterpolation, - }); - acc[input.id] = - typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? ''); - return acc; - }, - {} as Record - ); + const vars = createEmptyRecord>(); + for (const input of inputs) { + const value = input.getValue({ + interpolationContext: this.getInterpolationContext(), + skipLegacyOutputInterpolation, + }); + vars[input.id] = + typeof value === 'object' ? JSON.stringify(value) : (value?.toString() ?? ''); + } const interpolatedWithInputsAndGlobalContext = interpolateWithInputs( this.ctx.global.interpolate(template), vars @@ -556,7 +553,7 @@ export class BuildStep extends BuildStepOutputAccessor { const files = await fs.readdir(outputsDir); for (const outputId of files) { - if (!(outputId in this.outputById)) { + if (!Object.hasOwn(this.outputById, outputId)) { const newOutput = new BuildStepOutput(this.ctx.global, { id: outputId, stepDisplayName: this.displayName, diff --git a/packages/steps/src/BuildStepInput.ts b/packages/steps/src/BuildStepInput.ts index 2b7d61f505..3b45d8c102 100644 --- a/packages/steps/src/BuildStepInput.ts +++ b/packages/steps/src/BuildStepInput.ts @@ -8,6 +8,7 @@ import { BUILD_STEP_OR_BUILD_GLOBAL_CONTEXT_REFERENCE_REGEX, interpolateWithOutputs, } from './utils/template'; +import { createEmptyRecord } from './utils/record'; export enum BuildStepInputValueTypeName { STRING = 'string', @@ -255,12 +256,10 @@ export function getDisallowedInputValueError( return `Input parameter "${input.id}" for step "${stepDisplayName}" is set to "${rendered}" which is not one of the allowed values: ${allowedValues}.`; } -export function makeBuildStepInputByIdMap(inputs?: BuildStepInput[]): BuildStepInputById { - if (inputs === undefined) { - return {}; +export function makeBuildStepInputById(inputs?: BuildStepInput[]): BuildStepInputById { + const inputById = createEmptyRecord(); + for (const input of inputs ?? []) { + inputById[input.id] = input; } - return inputs.reduce((acc, input) => { - acc[input.id] = input; - return acc; - }, {} as BuildStepInputById); + return inputById; } diff --git a/packages/steps/src/BuildStepOutput.ts b/packages/steps/src/BuildStepOutput.ts index bb24839040..a286346446 100644 --- a/packages/steps/src/BuildStepOutput.ts +++ b/packages/steps/src/BuildStepOutput.ts @@ -1,5 +1,6 @@ import { BuildStepGlobalContext } from './BuildStepContext'; import { BuildStepRuntimeError } from './errors'; +import { createEmptyRecord } from './utils/record'; export type BuildStepOutputById = Record; export type BuildStepOutputProvider = ( @@ -92,12 +93,10 @@ export class BuildStepOutput { } } -export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputById { - if (outputs === undefined) { - return {}; +export function makeBuildStepOutputById(outputs?: BuildStepOutput[]): BuildStepOutputById { + const outputById = createEmptyRecord(); + for (const output of outputs ?? []) { + outputById[output.id] = output; } - return outputs.reduce((acc, output) => { - acc[output.id] = output; - return acc; - }, {} as BuildStepOutputById); + return outputById; } diff --git a/packages/steps/src/__tests__/BuildFunction-test.ts b/packages/steps/src/__tests__/BuildFunction-test.ts index ceaa4578cc..63e1bad232 100644 --- a/packages/steps/src/__tests__/BuildFunction-test.ts +++ b/packages/steps/src/__tests__/BuildFunction-test.ts @@ -310,6 +310,37 @@ describe(BuildFunction, () => { b: 2, }); }); + it('handles __proto__ as an input name', () => { + const ctx = createGlobalContextMock(); + const func = new BuildFunction({ + id: 'test1', + command: 'echo test', + inputProviders: [ + BuildStepInput.createProvider({ + id: '__proto__', + defaultValue: 'default', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + }); + + const stepWithoutValue = func.createBuildStepFromFunctionCall(ctx); + expect( + stepWithoutValue.inputs?.[0].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe('default'); + + const stepWithValue = func.createBuildStepFromFunctionCall(ctx, { + callInputs: Object.fromEntries([['__proto__', 'provided']]), + }); + expect( + stepWithValue.inputs?.[0].getValue({ + interpolationContext: ctx.getInterpolationContext(), + }) + ).toBe('provided'); + }); it('passes env to build step', () => { const ctx = createGlobalContextMock(); const func = new BuildFunction({ diff --git a/packages/steps/src/__tests__/BuildFunctionGroup-test.ts b/packages/steps/src/__tests__/BuildFunctionGroup-test.ts new file mode 100644 index 0000000000..f334ee1734 --- /dev/null +++ b/packages/steps/src/__tests__/BuildFunctionGroup-test.ts @@ -0,0 +1,35 @@ +import { createGlobalContextMock } from './utils/context'; +import { BuildFunctionGroup } from '../BuildFunctionGroup'; +import { BuildStepInput, BuildStepInputValueTypeName } from '../BuildStepInput'; + +describe(BuildFunctionGroup, () => { + it('handles __proto__ as an input name', () => { + const ctx = createGlobalContextMock(); + const receivedValues: unknown[] = []; + const group = new BuildFunctionGroup({ + namespace: 'test', + id: 'group', + inputProviders: [ + BuildStepInput.createProvider({ + id: '__proto__', + defaultValue: 'default', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + createBuildStepsFromFunctionGroupCall: (_ctx, { inputs }) => { + receivedValues.push( + inputs.__proto__.getValue({ interpolationContext: ctx.getInterpolationContext() }) + ); + return []; + }, + }); + + group.createBuildStepsFromFunctionGroupCall(ctx); + group.createBuildStepsFromFunctionGroupCall(ctx, { + callInputs: Object.fromEntries([['__proto__', 'provided']]), + }); + + expect(receivedValues).toEqual(['default', 'provided']); + }); +}); diff --git a/packages/steps/src/__tests__/BuildStep-test.ts b/packages/steps/src/__tests__/BuildStep-test.ts index 75bf0df5e8..e655458112 100644 --- a/packages/steps/src/__tests__/BuildStep-test.ts +++ b/packages/steps/src/__tests__/BuildStep-test.ts @@ -412,6 +412,34 @@ describe(BuildStep, () => { expect(step.getOutputValueByName('foo2')).toBe('bar linux {"foo":"bar","baz":[1,"aaa"]}'); }); + it('interpolates an input named __proto__ in a command template', async () => { + const step = new BuildStep(baseStepCtx, { + id: 'test1', + displayName: 'test1', + inputs: [ + new BuildStepInput(baseStepCtx, { + id: '__proto__', + stepDisplayName: 'test1', + defaultValue: 'prototype input', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + outputs: [ + new BuildStepOutput(baseStepCtx, { + id: 'result', + stepDisplayName: 'test1', + required: true, + }), + ], + command: "set-output result '${inputs.__proto__}'", + }); + + await step.executeAsync(); + + expect(step.getOutputValueByName('result')).toBe('prototype input'); + }); + it('interpolates the outputs in command template', async () => { const stepWithOutput = new BuildFunction({ id: 'func', @@ -812,6 +840,29 @@ describe(BuildStep, () => { expect(step.getOutputValueByName('abc')).toBe('bar1 bar2 true 27'); }); + + it('passes an input named __proto__ to the function', async () => { + const input = new BuildStepInput(baseStepCtx, { + id: '__proto__', + stepDisplayName: 'test1', + defaultValue: 'prototype input', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + const fn = jest.fn((_ctx, { inputs }) => { + expect(inputs.__proto__.value).toBe('prototype input'); + }); + const step = new BuildStep(baseStepCtx, { + id: 'test1', + displayName: 'test1', + inputs: [input], + fn, + }); + + await step.executeAsync(); + + expect(fn).toHaveBeenCalledTimes(1); + }); }); }); @@ -1159,6 +1210,46 @@ describe(BuildStep.deserialize, () => { expect(step.displayName).toBe('Test 1'); expect(step.getOutputValueByName('abc')).toBe('123'); }); + + it('preserves serialized output keys when they differ from embedded output ids', () => { + const step = BuildStep.deserialize({ + id: 'test1', + displayName: 'Test 1', + executed: true, + outputById: { + serialized_key: { + id: 'embedded_id', + stepDisplayName: 'Test 1', + required: true, + value: '123', + }, + }, + }); + + expect(step.getOutputValueByName('serialized_key')).toBe('123'); + expect(step.hasOutputParameter('embedded_id')).toBe(false); + }); + + it('deserializes an output keyed by __proto__', () => { + const step = BuildStep.deserialize({ + id: 'test1', + displayName: 'Test 1', + executed: true, + outputById: Object.fromEntries([ + [ + '__proto__', + { + id: '__proto__', + stepDisplayName: 'Test 1', + required: true, + value: '123', + }, + ], + ]), + }); + + expect(step.getOutputValueByName('__proto__')).toBe('123'); + }); }); describe(BuildStep.prototype.shouldExecuteStep, () => { @@ -1285,6 +1376,27 @@ describe(BuildStep.prototype.shouldExecuteStep, () => { expect(step.shouldExecuteStep()).toBe(true); }); + it('returns true when an input named __proto__ matches', () => { + const ctx = createGlobalContextMock(); + const step = new BuildStep(ctx, { + id: 'test1', + displayName: 'Test 1', + command: 'echo 123', + inputs: [ + new BuildStepInput(ctx, { + id: '__proto__', + stepDisplayName: 'Test 1', + defaultValue: 'prototype input', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + ifCondition: 'inputs.__proto__ === "prototype input"', + }); + + expect(step.shouldExecuteStep()).toBe(true); + }); + it('returns true when an eas value matches', () => { const ctx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }); const step = new BuildStep(ctx, { diff --git a/packages/steps/src/__tests__/BuildStepInput-test.ts b/packages/steps/src/__tests__/BuildStepInput-test.ts index 8a28e7c3ae..3c1daa5f54 100644 --- a/packages/steps/src/__tests__/BuildStepInput-test.ts +++ b/packages/steps/src/__tests__/BuildStepInput-test.ts @@ -5,7 +5,7 @@ import { BuildStep } from '../BuildStep'; import { BuildStepInput, BuildStepInputValueTypeName, - makeBuildStepInputByIdMap, + makeBuildStepInputById, } from '../BuildStepInput'; import { BuildStepRuntimeError } from '../errors'; @@ -942,12 +942,15 @@ describe(BuildStepInput, () => { }); }); -describe(makeBuildStepInputByIdMap, () => { - it('returns empty object when inputs are undefined', () => { - expect(makeBuildStepInputByIdMap(undefined)).toEqual({}); +describe(makeBuildStepInputById, () => { + it('returns an empty null-prototype object when inputs are undefined', () => { + const result = makeBuildStepInputById(undefined); + + expect(Object.keys(result)).toEqual([]); + expect(Object.getPrototypeOf(result)).toBeNull(); }); - it('returns object with inputs indexed by their ids', () => { + it('returns a null-prototype object with inputs indexed by their ids', () => { const ctx = createGlobalContextMock(); const inputs: BuildStepInput[] = [ new BuildStepInput(ctx, { @@ -972,8 +975,9 @@ describe(makeBuildStepInputByIdMap, () => { required: true, }), ]; - const result = makeBuildStepInputByIdMap(inputs); - expect(Object.keys(result).length).toBe(3); + const result = makeBuildStepInputById(inputs); + expect(Object.getPrototypeOf(result)).toBeNull(); + expect(Object.keys(result)).toHaveLength(3); expect(result.foo1).toBeDefined(); expect(result.foo2).toBeDefined(); expect(result.foo1.getValue({ interpolationContext: ctx.getInterpolationContext() })).toBe( @@ -986,4 +990,20 @@ describe(makeBuildStepInputByIdMap, () => { true ); }); + + it('supports input ids that are special object property names', () => { + const ctx = createGlobalContextMock(); + const input = new BuildStepInput(ctx, { + id: '__proto__', + stepDisplayName: 'test1', + defaultValue: 'value', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }); + + const result = makeBuildStepInputById([input]); + + expect(result.__proto__).toBe(input); + expect(Object.hasOwn(result, '__proto__')).toBe(true); + }); }); diff --git a/packages/steps/src/__tests__/BuildStepOutput-test.ts b/packages/steps/src/__tests__/BuildStepOutput-test.ts index fedbf5e755..d2dfb3a584 100644 --- a/packages/steps/src/__tests__/BuildStepOutput-test.ts +++ b/packages/steps/src/__tests__/BuildStepOutput-test.ts @@ -1,6 +1,6 @@ import { createGlobalContextMock } from './utils/context'; import { BuildStep } from '../BuildStep'; -import { BuildStepOutput, makeBuildStepOutputByIdMap } from '../BuildStepOutput'; +import { BuildStepOutput, makeBuildStepOutputById } from '../BuildStepOutput'; import { BuildStepRuntimeError } from '../errors'; describe(BuildStepOutput, () => { @@ -76,12 +76,15 @@ describe(BuildStepOutput, () => { }); }); -describe(makeBuildStepOutputByIdMap, () => { - it('returns empty object when inputs are undefined', () => { - expect(makeBuildStepOutputByIdMap(undefined)).toEqual({}); +describe(makeBuildStepOutputById, () => { + it('returns an empty null-prototype object when outputs are undefined', () => { + const result = makeBuildStepOutputById(undefined); + + expect(Object.keys(result)).toEqual([]); + expect(Object.getPrototypeOf(result)).toBeNull(); }); - it('returns object with outputs indexed by their ids', () => { + it('returns a null-prototype object with outputs indexed by their ids', () => { const ctx = createGlobalContextMock(); const outputs: BuildStepOutput[] = [ new BuildStepOutput(ctx, { @@ -95,9 +98,24 @@ describe(makeBuildStepOutputByIdMap, () => { required: true, }), ]; - const result = makeBuildStepOutputByIdMap(outputs); - expect(Object.keys(result).length).toBe(2); + const result = makeBuildStepOutputById(outputs); + expect(Object.getPrototypeOf(result)).toBeNull(); + expect(Object.keys(result)).toHaveLength(2); expect(result.abc1).toBeDefined(); expect(result.abc2).toBeDefined(); }); + + it('supports output ids that are special object property names', () => { + const ctx = createGlobalContextMock(); + const output = new BuildStepOutput(ctx, { + id: '__proto__', + stepDisplayName: 'test1', + required: true, + }); + + const result = makeBuildStepOutputById([output]); + + expect(result.__proto__).toBe(output); + expect(Object.hasOwn(result, '__proto__')).toBe(true); + }); }); diff --git a/packages/steps/src/__tests__/StepsConfigParser-test.ts b/packages/steps/src/__tests__/StepsConfigParser-test.ts index 0811db5a4d..561ca9982f 100644 --- a/packages/steps/src/__tests__/StepsConfigParser-test.ts +++ b/packages/steps/src/__tests__/StepsConfigParser-test.ts @@ -286,7 +286,7 @@ describe(StepsConfigParser, () => { expect(step1.ctx.workingDirectory).toBe(ctx.defaultWorkingDirectory); expect(step1.stepEnvOverrides).toEqual({}); expect(step1.inputs).toBeUndefined(); - expect(step1.outputById).toStrictEqual({}); + expect(Object.keys(step1.outputById)).toEqual([]); expect(step1.ifCondition).toBeUndefined(); const step2 = result.buildSteps[1]; @@ -299,7 +299,7 @@ describe(StepsConfigParser, () => { a: 'b', }); expect(step2.inputs).toBeUndefined(); - expect(step2.outputById).toStrictEqual({}); + expect(Object.keys(step2.outputById)).toEqual([]); expect(step2.ifCondition).toBeUndefined(); const step3 = result.buildSteps[2]; @@ -386,7 +386,7 @@ describe(StepsConfigParser, () => { expect(input4.defaultValue).toBeUndefined(); expect(input4.rawValue).toBe('${ step3.my_output }'); expect(input4.required).toBe(true); - expect(step4.outputById).toStrictEqual({}); + expect(Object.keys(step4.outputById)).toEqual([]); expect(step4.ifCondition).toBe('${ ctx.job.platform } == "android"'); }); }); diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js index ead9068a5b..6beb5aa4c2 100644 --- a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/build/index.js @@ -25,6 +25,7 @@ function myTsFunction(ctx, { inputs, outputs, env, }) { outputs.name.set('Brent'); outputs.num.set('123'); outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string + outputs.__proto__.set(inputs.__proto__.value); ctx.logger.info('Setting env vars'); env['MY_ENV_VAR'] = 'my-value'; }); diff --git a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts index 2f80c51770..f6baba2a72 100644 --- a/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts +++ b/packages/steps/src/__tests__/fixtures/my-custom-ts-function/src/index.ts @@ -10,12 +10,14 @@ interface MyTsFunctionInputs { name: BuildStepInput; num: BuildStepInput; obj: BuildStepInput; + __proto__: BuildStepInput; } interface MyTsFunctionOutputs { name: BuildStepOutput; num: BuildStepOutput; obj: BuildStepOutput; + __proto__: BuildStepOutput; } async function myTsFunctionAsync( @@ -45,6 +47,7 @@ async function myTsFunctionAsync( outputs.name.set('Brent'); outputs.num.set('123'); outputs.obj.set(JSON.stringify({ foo: 'bar' })); // TODO: add support for other types of outputs then string + outputs.__proto__.set(inputs.__proto__.value); ctx.logger.info('Setting env vars'); env['MY_ENV_VAR'] = 'my-value'; diff --git a/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts index 65809c798d..c6a825d5a9 100644 --- a/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts +++ b/packages/steps/src/scripts/__tests__/runCustomFunction-test.ts @@ -28,23 +28,16 @@ describe('runCustomFunction', () => { projectSourceDirectory, logger, }); - const outputs = { - name: new BuildStepOutput(ctx.global, { - id: 'name', - stepDisplayName: 'test', - required: true, - }), - num: new BuildStepOutput(ctx.global, { - id: 'num', - stepDisplayName: 'test', - required: true, - }), - obj: new BuildStepOutput(ctx.global, { - id: 'obj', - stepDisplayName: 'test', - required: true, - }), - }; + const outputs = Object.fromEntries( + ['name', 'num', 'obj', '__proto__'].map(id => [ + id, + new BuildStepOutput(ctx.global, { + id, + stepDisplayName: 'test', + required: true, + }), + ]) + ); const inputs = { name: new BuildStepInput(ctx.global, { id: 'name', @@ -68,6 +61,18 @@ describe('runCustomFunction', () => { inputs.name.set('foo'); inputs.num.set(123); inputs.obj.set({ foo: 'bar' }); + const inputsWithSpecialName = Object.fromEntries([ + ...Object.entries(inputs), + [ + '__proto__', + new BuildStepInput(ctx.global, { + id: '__proto__', + stepDisplayName: 'test', + required: true, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }).set('prototype input'), + ], + ]); try { const outputsDir = getTemporaryOutputsDirPath(ctx.global, 'test'); @@ -89,7 +94,7 @@ describe('runCustomFunction', () => { PATH: newPath, }, inputs: Object.fromEntries( - Object.entries(inputs).map(([id, input]) => [ + Object.entries(inputsWithSpecialName).map(([id, input]) => [ id, { value: input.getValue({ @@ -101,6 +106,8 @@ describe('runCustomFunction', () => { outputs, }); await expect(promise).resolves.not.toThrow(); + const rawPrototypeOutput = await fs.readFile(path.join(outputsDir, '__proto__'), 'utf-8'); + expect(Buffer.from(rawPrototypeOutput, 'base64').toString('utf-8')).toBe('prototype input'); } finally { await cleanUpStepTemporaryDirectoriesAsync(ctx.global, 'test'); } diff --git a/packages/steps/src/utils/__tests__/record-test.ts b/packages/steps/src/utils/__tests__/record-test.ts new file mode 100644 index 0000000000..329b5b24f9 --- /dev/null +++ b/packages/steps/src/utils/__tests__/record-test.ts @@ -0,0 +1,12 @@ +import { createEmptyRecord } from '../record'; + +describe(createEmptyRecord, () => { + it('creates a typed null-prototype record', () => { + const record = createEmptyRecord>(); + record.a = 1; + record.b = 2; + + expect(record).toEqual({ a: 1, b: 2 }); + expect(Object.getPrototypeOf(record)).toBeNull(); + }); +}); diff --git a/packages/steps/src/utils/record.ts b/packages/steps/src/utils/record.ts new file mode 100644 index 0000000000..67622d8017 --- /dev/null +++ b/packages/steps/src/utils/record.ts @@ -0,0 +1,4 @@ +/** Creates an empty dictionary that safely supports arbitrary keys such as `__proto__`. */ +export function createEmptyRecord>(): T { + return Object.create(null) as T; +}