< Summary - Envilder CLI

Information
Class: src/envilder/core/application/execWithSecrets/ExecWithSecretsCommandHandler.ts
Assembly: Default
File(s): src/envilder/core/application/execWithSecrets/ExecWithSecretsCommandHandler.ts
Tag: 561_41292037308
Line coverage
100%
Covered lines: 49
Uncovered lines: 0
Coverable lines: 49
Total lines: 200
Line coverage: 100%
Branch coverage
100%
Covered branches: 18
Total branches: 18
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/application/execWithSecrets/ExecWithSecretsCommandHandler.ts

#LineLine coverage
 1import { inject, injectable } from 'inversify';
 2import {
 3  buildChildEnvironment,
 4  findCaseCollisions,
 5} from '../../domain/ChildEnvironment.js';
 6import { exitCodeForChild } from '../../domain/ExecExitCode.js';
 7import {
 8  ExecInterruptedError,
 9  ExpiredCredentialsError,
 10  InvalidArgumentError,
 11  SecretsFetchError,
 12  SsoSessionExpiredError,
 13} from '../../domain/errors/DomainErrors.js';
 14import type { ILogger } from '../../domain/ports/ILogger.js';
 15import type { IProcessContext } from '../../domain/ports/IProcessContext.js';
 16import type { IProcessRunner } from '../../domain/ports/IProcessRunner.js';
 17import type { ISecretMasker } from '../../domain/ports/ISecretMasker.js';
 18import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js';
 19import type { IVariableStore } from '../../domain/ports/IVariableStore.js';
 20import { TYPES } from '../../types.js';
 21import {
 22  type FailedSecret,
 23  SecretResolver,
 24  type UnresolvedSecret,
 25} from '../secretResolution/SecretResolver.js';
 26import { formatProgram } from './CommandLineFormatter.js';
 27import type { ExecWithSecretsCommand } from './ExecWithSecretsCommand.js';
 28
 29/**
 30 * Secrets resolved for an exec command, ready to start the child.
 31 */
 32export type PreparedExec = {
 33  readonly command: ExecWithSecretsCommand;
 34  readonly secrets: Readonly<Record<string, string>>;
 35};
 36
 837const NUL = '\0';
 38
 39/**
 40 * Runs a command with the secrets of a map file in its environment, without
 41 * writing anything to disk.
 42 *
 43 * Exec mode is fail-closed: the child is never started when a secret is
 44 * missing, empty or unreadable. `handle` is the entry point (reached through
 45 * the dispatcher): `prepare` resolves every secret and `start` runs the child
 46 * once. Only `prepare` raises retriable errors, such as an expired SSO
 47 * session; `start` turns any SSO or credentials error into a non-retriable
 48 * `ExecInterruptedError`, so retrying `handle` never starts the child twice.
 49 */
 50@injectable()
 851export class ExecWithSecretsCommandHandler {
 52  private readonly secretResolver: SecretResolver;
 53
 54  constructor(
 55    @inject(TYPES.ISecretProvider)
 56    secretProvider: ISecretProvider,
 57    @inject(TYPES.IVariableStore)
 7358    private readonly variableStore: IVariableStore,
 7359    @inject(TYPES.ILogger) private readonly logger: ILogger,
 60    @inject(TYPES.ISecretMasker)
 7361    private readonly secretMasker: ISecretMasker,
 62    @inject(TYPES.IProcessRunner)
 7363    private readonly processRunner: IProcessRunner,
 64    @inject(TYPES.IProcessContext)
 7365    private readonly processContext: IProcessContext,
 66  ) {
 7367    this.secretResolver = new SecretResolver(
 68      secretProvider,
 69      logger,
 70      secretMasker,
 71    );
 72  }
 73
 74  /**
 75   * Resolves the secrets and runs the child. Returns the exit code Envilder
 76   * must exit with: the child's code, or `128 + n` when a signal killed it.
 77   */
 78  async handle(command: ExecWithSecretsCommand): Promise<number> {
 7679    const prepared = await this.prepare(command);
 5680    return this.start(prepared);
 81  }
 82
 83  /**
 84   * Resolves every secret declared in the map file. Never reads or writes a
 85   * `.env` file. Throws `SecretsFetchError` when any secret does not resolve.
 86   */
 87  async prepare(command: ExecWithSecretsCommand): Promise<PreparedExec> {
 7688    const mappings = await this.variableStore.getMapping(command.mapPath);
 7689    if (Object.keys(mappings).length === 0) {
 190      this.logger.warn('Map file declares no variables.');
 91    }
 7692    this.assertNoCaseCollisions(Object.keys(mappings));
 93
 7694    const { outcomes, resolved } = await this.secretResolver.resolve(mappings);
 6695    const failures = outcomes.filter(
 96      (outcome): outcome is UnresolvedSecret | FailedSecret =>
 12597        outcome.status !== 'resolved',
 98    );
 6699    if (failures.length > 0) {
 8100      this.secretResolver.logSecretsSection(resolved, []);
 8101      throw new SecretsFetchError(failures.map(SecretResolver.toFailure));
 102    }
 103
 58104    const secrets: Record<string, string> = {};
 58105    for (const { envVar, value } of resolved) {
 107106      if (value.includes(NUL)) {
 2107        throw new InvalidArgumentError(
 108          `Secret for ${envVar} contains a NUL byte and cannot be passed as an environment variable.`,
 109        );
 110      }
 105111      secrets[envVar] = value;
 112    }
 113
 56114    return { command, secrets };
 115  }
 116
 117  /**
 118   * Starts the child once with the inherited environment plus the resolved
 119   * secrets (the map file wins) and waits for it to exit.
 120   *
 121   * SSO and credentials errors are only retriable before this point. Raised
 122   * from here on, they become a non-retriable `ExecInterruptedError` so a
 123   * caller's recovery never runs the command again.
 124   */
 125  async start(prepared: PreparedExec): Promise<number> {
 56126    try {
 56127      return await this.supervise(prepared);
 128    } catch (error) {
 10129      if (
 130        error instanceof SsoSessionExpiredError ||
 131        error instanceof ExpiredCredentialsError
 132      ) {
 4133        throw new ExecInterruptedError(error);
 134      }
 6135      throw error;
 136    }
 137  }
 138
 139  private async supervise(prepared: PreparedExec): Promise<number> {
 56140    const { command, secrets } = prepared;
 141    // The child shares stdout with the mask registrations: start it only
 142    // once every registration has been written.
 56143    await this.secretMasker.flush();
 52144    const { env, overridden } = buildChildEnvironment(
 145      this.processContext.environment(),
 146      secrets,
 147      this.processContext.platform(),
 148    );
 149
 52150    if (overridden.length > 0) {
 5151      this.logger.warn(
 152        `Overriding inherited variables: ${overridden.join(', ')}`,
 153      );
 154    }
 52155    this.logger.info(
 156      ExecWithSecretsCommandHandler.buildSummary(
 157        Object.keys(secrets).length,
 158        command,
 159      ),
 160    );
 161
 52162    const exit = await this.processRunner.run({
 163      command: command.command,
 164      args: [...command.args],
 165      env,
 166    });
 167
 45168    return exitCodeForChild(exit, (signal) =>
 3169      this.processContext.signalNumber(signal),
 170    );
 171  }
 172
 173  /**
 174   * On Windows two mapped names that differ only by case are one variable,
 175   * so one value would be silently dropped. Refuse before resolving.
 176   */
 177  private assertNoCaseCollisions(names: string[]): void {
 76178    const collisions = findCaseCollisions(
 179      names,
 180      this.processContext.platform(),
 181    );
 76182    if (collisions.length === 0) {
 74183      return;
 184    }
 2185    const listed = collisions.map((group) => group.join(', ')).join('; ');
 2186    throw new InvalidArgumentError(
 187      `Map file declares variables that differ only by case (${listed}); Windows treats them as the same variable.`,
 188    );
 189  }
 190
 191  private static buildSummary(
 192    count: number,
 193    command: ExecWithSecretsCommand,
 194  ): string {
 52195    const noun = count === 1 ? 'secret' : 'secrets';
 52196    const source = command.sourceLabel ? ` (${command.sourceLabel})` : '';
 52197    const commandLine = formatProgram(command.command, command.args);
 52198    return `envilder: ${count} ${noun} resolved${source} \u2192 ${commandLine}`;
 199  }
 200}