| | | 1 | | import { inject, injectable } from 'inversify'; |
| | | 2 | | import { |
| | | 3 | | buildChildEnvironment, |
| | | 4 | | findCaseCollisions, |
| | | 5 | | } from '../../domain/ChildEnvironment.js'; |
| | | 6 | | import { exitCodeForChild } from '../../domain/ExecExitCode.js'; |
| | | 7 | | import { |
| | | 8 | | ExecInterruptedError, |
| | | 9 | | ExpiredCredentialsError, |
| | | 10 | | InvalidArgumentError, |
| | | 11 | | SecretsFetchError, |
| | | 12 | | SsoSessionExpiredError, |
| | | 13 | | } from '../../domain/errors/DomainErrors.js'; |
| | | 14 | | import type { ILogger } from '../../domain/ports/ILogger.js'; |
| | | 15 | | import type { IProcessContext } from '../../domain/ports/IProcessContext.js'; |
| | | 16 | | import type { IProcessRunner } from '../../domain/ports/IProcessRunner.js'; |
| | | 17 | | import type { ISecretMasker } from '../../domain/ports/ISecretMasker.js'; |
| | | 18 | | import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js'; |
| | | 19 | | import type { IVariableStore } from '../../domain/ports/IVariableStore.js'; |
| | | 20 | | import { TYPES } from '../../types.js'; |
| | | 21 | | import { |
| | | 22 | | type FailedSecret, |
| | | 23 | | SecretResolver, |
| | | 24 | | type UnresolvedSecret, |
| | | 25 | | } from '../secretResolution/SecretResolver.js'; |
| | | 26 | | import { formatProgram } from './CommandLineFormatter.js'; |
| | | 27 | | import type { ExecWithSecretsCommand } from './ExecWithSecretsCommand.js'; |
| | | 28 | | |
| | | 29 | | /** |
| | | 30 | | * Secrets resolved for an exec command, ready to start the child. |
| | | 31 | | */ |
| | | 32 | | export type PreparedExec = { |
| | | 33 | | readonly command: ExecWithSecretsCommand; |
| | | 34 | | readonly secrets: Readonly<Record<string, string>>; |
| | | 35 | | }; |
| | | 36 | | |
| | 8 | 37 | | const 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() |
| | 8 | 51 | | export class ExecWithSecretsCommandHandler { |
| | | 52 | | private readonly secretResolver: SecretResolver; |
| | | 53 | | |
| | | 54 | | constructor( |
| | | 55 | | @inject(TYPES.ISecretProvider) |
| | | 56 | | secretProvider: ISecretProvider, |
| | | 57 | | @inject(TYPES.IVariableStore) |
| | 73 | 58 | | private readonly variableStore: IVariableStore, |
| | 73 | 59 | | @inject(TYPES.ILogger) private readonly logger: ILogger, |
| | | 60 | | @inject(TYPES.ISecretMasker) |
| | 73 | 61 | | private readonly secretMasker: ISecretMasker, |
| | | 62 | | @inject(TYPES.IProcessRunner) |
| | 73 | 63 | | private readonly processRunner: IProcessRunner, |
| | | 64 | | @inject(TYPES.IProcessContext) |
| | 73 | 65 | | private readonly processContext: IProcessContext, |
| | | 66 | | ) { |
| | 73 | 67 | | 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> { |
| | 76 | 79 | | const prepared = await this.prepare(command); |
| | 56 | 80 | | 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> { |
| | 76 | 88 | | const mappings = await this.variableStore.getMapping(command.mapPath); |
| | 76 | 89 | | if (Object.keys(mappings).length === 0) { |
| | 1 | 90 | | this.logger.warn('Map file declares no variables.'); |
| | | 91 | | } |
| | 76 | 92 | | this.assertNoCaseCollisions(Object.keys(mappings)); |
| | | 93 | | |
| | 76 | 94 | | const { outcomes, resolved } = await this.secretResolver.resolve(mappings); |
| | 66 | 95 | | const failures = outcomes.filter( |
| | | 96 | | (outcome): outcome is UnresolvedSecret | FailedSecret => |
| | 125 | 97 | | outcome.status !== 'resolved', |
| | | 98 | | ); |
| | 66 | 99 | | if (failures.length > 0) { |
| | 8 | 100 | | this.secretResolver.logSecretsSection(resolved, []); |
| | 8 | 101 | | throw new SecretsFetchError(failures.map(SecretResolver.toFailure)); |
| | | 102 | | } |
| | | 103 | | |
| | 58 | 104 | | const secrets: Record<string, string> = {}; |
| | 58 | 105 | | for (const { envVar, value } of resolved) { |
| | 107 | 106 | | if (value.includes(NUL)) { |
| | 2 | 107 | | throw new InvalidArgumentError( |
| | | 108 | | `Secret for ${envVar} contains a NUL byte and cannot be passed as an environment variable.`, |
| | | 109 | | ); |
| | | 110 | | } |
| | 105 | 111 | | secrets[envVar] = value; |
| | | 112 | | } |
| | | 113 | | |
| | 56 | 114 | | 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> { |
| | 56 | 126 | | try { |
| | 56 | 127 | | return await this.supervise(prepared); |
| | | 128 | | } catch (error) { |
| | 10 | 129 | | if ( |
| | | 130 | | error instanceof SsoSessionExpiredError || |
| | | 131 | | error instanceof ExpiredCredentialsError |
| | | 132 | | ) { |
| | 4 | 133 | | throw new ExecInterruptedError(error); |
| | | 134 | | } |
| | 6 | 135 | | throw error; |
| | | 136 | | } |
| | | 137 | | } |
| | | 138 | | |
| | | 139 | | private async supervise(prepared: PreparedExec): Promise<number> { |
| | 56 | 140 | | const { command, secrets } = prepared; |
| | | 141 | | // The child shares stdout with the mask registrations: start it only |
| | | 142 | | // once every registration has been written. |
| | 56 | 143 | | await this.secretMasker.flush(); |
| | 52 | 144 | | const { env, overridden } = buildChildEnvironment( |
| | | 145 | | this.processContext.environment(), |
| | | 146 | | secrets, |
| | | 147 | | this.processContext.platform(), |
| | | 148 | | ); |
| | | 149 | | |
| | 52 | 150 | | if (overridden.length > 0) { |
| | 5 | 151 | | this.logger.warn( |
| | | 152 | | `Overriding inherited variables: ${overridden.join(', ')}`, |
| | | 153 | | ); |
| | | 154 | | } |
| | 52 | 155 | | this.logger.info( |
| | | 156 | | ExecWithSecretsCommandHandler.buildSummary( |
| | | 157 | | Object.keys(secrets).length, |
| | | 158 | | command, |
| | | 159 | | ), |
| | | 160 | | ); |
| | | 161 | | |
| | 52 | 162 | | const exit = await this.processRunner.run({ |
| | | 163 | | command: command.command, |
| | | 164 | | args: [...command.args], |
| | | 165 | | env, |
| | | 166 | | }); |
| | | 167 | | |
| | 45 | 168 | | return exitCodeForChild(exit, (signal) => |
| | 3 | 169 | | 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 { |
| | 76 | 178 | | const collisions = findCaseCollisions( |
| | | 179 | | names, |
| | | 180 | | this.processContext.platform(), |
| | | 181 | | ); |
| | 76 | 182 | | if (collisions.length === 0) { |
| | 74 | 183 | | return; |
| | | 184 | | } |
| | 2 | 185 | | const listed = collisions.map((group) => group.join(', ')).join('; '); |
| | 2 | 186 | | 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 { |
| | 52 | 195 | | const noun = count === 1 ? 'secret' : 'secrets'; |
| | 52 | 196 | | const source = command.sourceLabel ? ` (${command.sourceLabel})` : ''; |
| | 52 | 197 | | const commandLine = formatProgram(command.command, command.args); |
| | 52 | 198 | | return `envilder: ${count} ${noun} resolved${source} \u2192 ${commandLine}`; |
| | | 199 | | } |
| | | 200 | | } |