Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion packages/steps/src/BuildFunction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions packages/steps/src/BuildFunctionGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { BuildStepGlobalContext } from './BuildStepContext';
import {
BuildStepInputById,
BuildStepInputProvider,
makeBuildStepInputByIdMap,
makeBuildStepInputById,
} from './BuildStepInput';
import { BuildConfigError } from './errors';

Expand Down Expand Up @@ -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),
});
};
}
Expand Down
93 changes: 45 additions & 48 deletions packages/steps/src/BuildStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -50,14 +51,16 @@ export enum BuildStepLogMarker {
END_STEP = 'end-step',
}

export type BuildStepFunctionInputs = Record<string, { value: unknown }>;

export type BuildStepFunction = (
ctx: BuildStepContext,
{
inputs,
outputs,
env,
}: {
inputs: { [key: string]: { value: unknown } };
inputs: BuildStepFunctionInputs;
outputs: BuildStepOutputById;
env: BuildStepEnv;
signal?: AbortSignal;
Expand Down Expand Up @@ -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 {
Expand All @@ -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<BuildStepOutputById>();
for (const [key, value] of Object.entries(serialized.outputById)) {
outputById[key] = BuildStepOutput.deserialize(value);
}
return new BuildStepOutputAccessor(
serialized.id,
serialized.displayName,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -351,18 +352,14 @@ export class BuildStep extends BuildStepOutputAccessor {
}

private evaluateOwnStepInputs(): Record<string, unknown> {
return (
this.inputs?.reduce(
(acc, input) => {
acc[input.id] = input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
});
return acc;
},
{} as Record<string, unknown>
) ?? {}
);
const inputsById = createEmptyRecord<Record<string, unknown>>();
for (const input of this.inputs ?? []) {
inputsById[input.id] = input.getValue({
interpolationContext: this.getInterpolationContext(),
skipLegacyOutputInterpolation: this.compositeFunctionScope !== undefined,
});
}
return inputsById;
}

private evaluateIfCondition(
Expand Down Expand Up @@ -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,
Expand All @@ -510,6 +497,19 @@ export class BuildStep extends BuildStepOutputAccessor {
this.ctx.logger.debug(`Script completed successfully`);
}

private getFunctionInputs(): BuildStepFunctionInputs {
const functionInputs = createEmptyRecord<BuildStepFunctionInputs>();
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[]
Expand All @@ -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<string, string>
);
const vars = createEmptyRecord<Record<string, string>>();
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
Expand All @@ -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,
Expand Down
13 changes: 6 additions & 7 deletions packages/steps/src/BuildStepInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<BuildStepInputById>();
for (const input of inputs ?? []) {
inputById[input.id] = input;
}
return inputs.reduce((acc, input) => {
acc[input.id] = input;
return acc;
}, {} as BuildStepInputById);
return inputById;
}
13 changes: 6 additions & 7 deletions packages/steps/src/BuildStepOutput.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BuildStepGlobalContext } from './BuildStepContext';
import { BuildStepRuntimeError } from './errors';
import { createEmptyRecord } from './utils/record';

export type BuildStepOutputById = Record<string, BuildStepOutput>;
export type BuildStepOutputProvider = (
Expand Down Expand Up @@ -92,12 +93,10 @@ export class BuildStepOutput<R extends boolean = boolean> {
}
}

export function makeBuildStepOutputByIdMap(outputs?: BuildStepOutput[]): BuildStepOutputById {
if (outputs === undefined) {
return {};
export function makeBuildStepOutputById(outputs?: BuildStepOutput[]): BuildStepOutputById {
const outputById = createEmptyRecord<BuildStepOutputById>();
for (const output of outputs ?? []) {
outputById[output.id] = output;
}
return outputs.reduce((acc, output) => {
acc[output.id] = output;
return acc;
}, {} as BuildStepOutputById);
return outputById;
}
31 changes: 31 additions & 0 deletions packages/steps/src/__tests__/BuildFunction-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
35 changes: 35 additions & 0 deletions packages/steps/src/__tests__/BuildFunctionGroup-test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading
Loading