cdk-local 0.149.7 → 0.149.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1659 +0,0 @@
1
- import { readlinkSync, realpathSync } from "node:fs";
2
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
- import { spinner } from "@clack/prompts";
4
- import { spawn } from "node:child_process";
5
-
6
- //#region \0rolldown/runtime.js
7
- var __defProp = Object.defineProperty;
8
- var __exportAll = (all, no_symbols) => {
9
- let target = {};
10
- for (var name in all) {
11
- __defProp(target, name, {
12
- get: all[name],
13
- enumerable: true
14
- });
15
- }
16
- if (!no_symbols) {
17
- __defProp(target, Symbol.toStringTag, { value: "Module" });
18
- }
19
- return target;
20
- };
21
-
22
- //#endregion
23
- //#region src/local/embed-config.ts
24
- const DEFAULTS = {
25
- cliName: "cdkl",
26
- binaryName: "cdkl",
27
- productName: "cdk-local",
28
- resourceNamePrefix: "cdkl",
29
- awsBindMountPath: "/cdk-local-aws",
30
- envPrefix: "CDKL",
31
- sigV4StrictByDefault: false,
32
- sigV4OptFlag: "--strict-sigv4"
33
- };
34
- let current = DEFAULTS;
35
- /**
36
- * Resolve and install the active embed config. Called once per command
37
- * factory with the host's overrides (or `undefined` for native cdkl
38
- * behavior). Idempotent: re-calling with the same overrides is a no-op,
39
- * which is why all four factories may safely set the same config.
40
- */
41
- function setEmbedConfig(config) {
42
- current = {
43
- cliName: config?.cliName ?? DEFAULTS.cliName,
44
- binaryName: config?.binaryName ?? DEFAULTS.binaryName,
45
- productName: config?.productName ?? DEFAULTS.productName,
46
- resourceNamePrefix: config?.resourceNamePrefix ?? DEFAULTS.resourceNamePrefix,
47
- awsBindMountPath: config?.awsBindMountPath ?? DEFAULTS.awsBindMountPath,
48
- envPrefix: config?.envPrefix ?? DEFAULTS.envPrefix,
49
- sigV4StrictByDefault: config?.sigV4StrictByDefault ?? DEFAULTS.sigV4StrictByDefault,
50
- sigV4OptFlag: config?.sigV4OptFlag ?? DEFAULTS.sigV4OptFlag
51
- };
52
- }
53
- /** The active resolved embed config. */
54
- function getEmbedConfig() {
55
- return current;
56
- }
57
- /** Restore cdk-local defaults. Primarily a test-isolation helper. */
58
- function resetEmbedConfig() {
59
- current = DEFAULTS;
60
- }
61
-
62
- //#endregion
63
- //#region src/utils/logger.ts
64
- /**
65
- * ANSI color codes
66
- *
67
- * Kept internal — `ConsoleLogger.formatMessage` references these for the
68
- * verbose/compact mode level prefixes.
69
- */
70
- const colors = {
71
- reset: "\x1B[0m",
72
- bright: "\x1B[1m",
73
- dim: "\x1B[2m",
74
- red: "\x1B[31m",
75
- green: "\x1B[32m",
76
- yellow: "\x1B[33m",
77
- blue: "\x1B[34m",
78
- cyan: "\x1B[36m",
79
- gray: "\x1B[90m"
80
- };
81
- function formatTimestamp() {
82
- return (/* @__PURE__ */ new Date()).toISOString();
83
- }
84
- /**
85
- * Resolve whether ANSI color should be emitted by default.
86
- *
87
- * Color is appropriate for an interactive terminal but is noise (literal
88
- * `\x1b[31m...` escapes) when the output is a pipe — e.g. when `cdkl studio`
89
- * spawns `cdkl invoke` / a serve command as a child and captures its output to
90
- * render in the browser, where the raw escapes leak as visible text. So the
91
- * default tracks the stdout TTY-ness, with the two standard env overrides:
92
- *
93
- * - `NO_COLOR` (any non-empty value) forces colors OFF (https://no-color.org).
94
- * - `FORCE_COLOR` (non-empty, not `'0'` / `'false'`) forces colors ON, even
95
- * when not a TTY (the convention many CLIs honor for CI / log capture).
96
- * - otherwise: on when `process.stdout.isTTY`.
97
- *
98
- * We gate on `process.stdout.isTTY` even though warn / error go to stderr via
99
- * `console.error` / `console.warn`. In the case this fix targets — a piped
100
- * child (e.g. studio) — NEITHER stdout nor stderr is a TTY, so gating on stdout
101
- * still yields colorless output. Tracking stdout matches common CLI tooling and
102
- * keeps a single, predictable signal; an explicit `useColors` argument (passed
103
- * by child loggers) always overrides this default.
104
- */
105
- function resolveDefaultUseColors() {
106
- const noColor = process.env["NO_COLOR"];
107
- if (noColor !== void 0 && noColor !== "") return false;
108
- const forceColor = process.env["FORCE_COLOR"];
109
- if (forceColor !== void 0 && forceColor !== "" && forceColor !== "0" && forceColor !== "false") return true;
110
- return !!process.stdout.isTTY;
111
- }
112
- /**
113
- * Console logger implementation
114
- *
115
- * Supports two output modes:
116
- * - verbose (debug level): timestamps, module prefixes, all details
117
- * - compact (info level): clean output without timestamps or prefixes
118
- */
119
- var ConsoleLogger = class {
120
- level;
121
- useColors;
122
- constructor(level = "info", useColors = resolveDefaultUseColors()) {
123
- this.level = level;
124
- this.useColors = useColors;
125
- }
126
- shouldLog(level) {
127
- const levels = [
128
- "debug",
129
- "info",
130
- "warn",
131
- "error"
132
- ];
133
- const currentLevelIndex = levels.indexOf(this.level);
134
- return levels.indexOf(level) >= currentLevelIndex;
135
- }
136
- formatMessage(level, message, ...args) {
137
- const formattedArgs = args.length > 0 ? " " + args.map((a) => JSON.stringify(a)).join(" ") : "";
138
- if (this.level === "debug") {
139
- const timestamp = formatTimestamp();
140
- const levelStr = level.toUpperCase().padEnd(5);
141
- if (this.useColors) {
142
- const levelColor = {
143
- debug: colors.gray,
144
- info: colors.blue,
145
- warn: colors.yellow,
146
- error: colors.red
147
- }[level];
148
- return `${colors.dim}${timestamp}${colors.reset} ${levelColor}${levelStr}${colors.reset} ${message}${formattedArgs}`;
149
- }
150
- return `${timestamp} ${levelStr} ${message}${formattedArgs}`;
151
- }
152
- if (level === "error") {
153
- const line = `ERROR: ${message}${formattedArgs}`;
154
- return this.useColors ? `${colors.red}${line}${colors.reset}` : line;
155
- }
156
- if (level === "warn") {
157
- const line = `WARN: ${message}${formattedArgs}`;
158
- return this.useColors ? `${colors.yellow}${line}${colors.reset}` : line;
159
- }
160
- return `${message}${formattedArgs}`;
161
- }
162
- emit(level, formatted) {
163
- if (process.env["CDKL_LOG_STREAM"] === "stdout") {
164
- console.log(formatted);
165
- return;
166
- }
167
- if (level === "error") console.error(formatted);
168
- else if (level === "warn") console.warn(formatted);
169
- else if (level === "info") console.info(formatted);
170
- else console.debug(formatted);
171
- }
172
- debug(message, ...args) {
173
- if (this.shouldLog("debug")) this.emit("debug", this.formatMessage("debug", message, ...args));
174
- }
175
- info(message, ...args) {
176
- if (this.shouldLog("info")) this.emit("info", this.formatMessage("info", message, ...args));
177
- }
178
- warn(message, ...args) {
179
- if (this.shouldLog("warn")) this.emit("warn", this.formatMessage("warn", message, ...args));
180
- }
181
- error(message, ...args) {
182
- if (this.shouldLog("error")) this.emit("error", this.formatMessage("error", message, ...args));
183
- }
184
- setLevel(level) {
185
- this.level = level;
186
- }
187
- getLevel() {
188
- return this.level;
189
- }
190
- child(prefix) {
191
- return new ChildLogger(prefix, this.useColors);
192
- }
193
- };
194
- /**
195
- * Child logger that always syncs level from global logger
196
- */
197
- var ChildLogger = class extends ConsoleLogger {
198
- prefix;
199
- constructor(prefix, useColors) {
200
- super("info", useColors);
201
- this.prefix = prefix;
202
- }
203
- syncLevel() {
204
- if (globalLogger) this.setLevel(globalLogger.getLevel());
205
- }
206
- debug(message, ...args) {
207
- this.syncLevel();
208
- super.debug(`[${this.prefix}] ${message}`, ...args);
209
- }
210
- info(message, ...args) {
211
- this.syncLevel();
212
- const msg = this.getLevel() === "debug" ? `[${this.prefix}] ${message}` : message;
213
- super.info(msg, ...args);
214
- }
215
- warn(message, ...args) {
216
- this.syncLevel();
217
- const msg = this.getLevel() === "debug" ? `[${this.prefix}] ${message}` : message;
218
- super.warn(msg, ...args);
219
- }
220
- error(message, ...args) {
221
- this.syncLevel();
222
- const msg = this.getLevel() === "debug" ? `[${this.prefix}] ${message}` : message;
223
- super.error(msg, ...args);
224
- }
225
- };
226
- /**
227
- * Resolve the initial log level from the `CDKL_LOG_LEVEL` env var, falling
228
- * back to `'info'`. This is primarily an internal contract: `cdkl studio`
229
- * spawns its single-shot `cdkl invoke` child with `CDKL_LOG_LEVEL=warn` so
230
- * cdk-local's OWN synth / orchestration progress (toolkit "Successfully
231
- * synthesized to ...", asset-bundling lines, info-level status) is silenced
232
- * in the child — leaving the studio LOGS panel showing only the Lambda
233
- * container's runtime logs (which stream straight from `docker logs` and are
234
- * unaffected by this level) plus the response. `--verbose` still overrides to
235
- * `debug` at the command layer. An invalid value is ignored.
236
- */
237
- function resolveConfiguredLogLevel() {
238
- const env = process.env["CDKL_LOG_LEVEL"];
239
- if (env === "debug" || env === "info" || env === "warn" || env === "error") return env;
240
- return "info";
241
- }
242
- let globalLogger = null;
243
- function getLogger() {
244
- if (!globalLogger) globalLogger = new ConsoleLogger(resolveConfiguredLogLevel());
245
- return globalLogger;
246
- }
247
-
248
- //#endregion
249
- //#region src/local/credential-error.ts
250
- /**
251
- * Rendering an AWS SDK failure into a log line cdk-local is willing to print
252
- * at DEFAULT level (issues #564, #570).
253
- *
254
- * # Why a shared module
255
- *
256
- * Issue #564 settled the policy for one site — the credential-chain failure
257
- * in {@link file://./sigv4-verify.ts} — and #570 found the same shape at nine
258
- * more, spread across five `src/cli/commands/*.ts` files. Every one of them
259
- * relays a third-party error's `message` into a `logger.warn`, so they share
260
- * one question and must not grow nine answers to it.
261
- *
262
- * SCOPE, stated so a later sweep finds a decision rather than an oversight:
263
- * this module governs those nine plus sigv4-verify's one, and its
264
- * {@link flattenToOneLine} additionally covers every other wire-derived value
265
- * printed on those lines (the role ARN, on the failure AND success paths).
266
- *
267
- * Issue #579 extended it to the AWS SDK error relays elsewhere under
268
- * `src/local/**` (plus `local-studio.ts`'s image-context warn), so the policy
269
- * is no longer scoped to `src/cli/commands/**`:
270
- * `cfn-local-state-provider.ts` (`formatAwsErrorForWarn`, six callers),
271
- * `ssm-parameter-resolver.ts` (whose `formatSsmError` was DELETED rather than
272
- * fixed — it was a second spelling of the same forging vector),
273
- * `state-resolver.ts`, `cloudfront-kvs-client.ts`, `cloudfront-s3-origin.ts`,
274
- * `layer-arn-materializer.ts`, `ecr-puller.ts`, `ecs-secrets-resolver.ts` and
275
- * `httpv2-service-integration.ts`. Each was decided on the SAME two axes below
276
- * rather than rewritten mechanically, and a `catch` around a purely LOCAL
277
- * operation was left alone: `agentcore-s3-bundle.ts`'s unzip and
278
- * `layer-arn-materializer.ts`'s presigned-URL download / unzip see no
279
- * credential chain and no service response, so there is nothing here for them.
280
- *
281
- * The test is what a `catch` CAN SEE, and applying it by LOCATION instead is
282
- * how the sweep first got `agentcore-s3-bundle.ts` wrong. Its `try` does wrap
283
- * `unzipSync` and nothing else — but that made the S3 `GetObject` above it
284
- * UNCOVERED, not out of scope: it had no `catch` at all, so the raw SDK error
285
- * propagated to `withErrorHandling` -> `formatError`, which prints
286
- * `${error.name}: ${error.message}` unflattened and unclamped at DEFAULT
287
- * level, and the default credential chain is what a run without
288
- * `--assume-role` uses. "Outside that `catch`" is not "outside the sweep";
289
- * ask which populations reach the site, and if the answer is "nothing catches
290
- * it here", follow it to the frame that does.
291
- *
292
- * #579 also widened what LEVEL means, and the widening is the useful part:
293
- * `httpv2-service-integration.ts`'s relay is not a log line at all, it is a
294
- * served HTTP RESPONSE BODY — the widest reader in the sweep, since it needs
295
- * neither `--verbose` nor access to the terminal, and the studio capture proxy
296
- * records it onto the timeline besides. The axis is about READERS, not about
297
- * logs.
298
- *
299
- * TWO CALLING CONVENTIONS this imposes, both found by #579 rather than
300
- * anticipated here:
301
- *
302
- * 1. Render each failure ONCE. {@link describeAwsFailureForWarn} EMITS the
303
- * `debug` line, so calling it twice for one error prints that line twice
304
- * (`cfn-local-state-provider.ts`'s `load` did, having rendered the same
305
- * failure for its warn and for `lastLoadError`).
306
- * 2. Re-raise cdk-local's OWN throws ABOVE the relay, with an identifiable
307
- * class. The discriminator is positive, so a `catch` that also catches
308
- * the caller's own `throw new Error('...')` withholds text cdk-local
309
- * wrote itself — which is the diagnostic loss this file's
310
- * {@link describeCredentialLoadFailure} note calls "a real diagnostic
311
- * loss, accepted". #579 stopped accepting it where it was cheap not to:
312
- * `layer-arn-materializer.ts`'s ARN-shape and missing-`Content.Location`
313
- * guards became `LayerMaterializationError`s and are re-raised, and
314
- * `httpv2-service-integration.ts`'s missing-RequestParameter 400 became a
315
- * `ServiceIntegrationRequestError` so an actionable 400 body did not
316
- * degrade into a class name and a character count.
317
- *
318
- * TWO CATCH-ALL relays remain outside it, and both are UNCOVERED. An earlier
319
- * revision of this note gave them opposite verdicts in adjacent paragraphs —
320
- * clearing one and rejecting the other on the same evidence — which was simply
321
- * wrong about the first, so both now read the same way:
322
- *
323
- * - `cloudfront-server.ts:129`'s `Request handling failed: ${err.message}`.
324
- * It is what makes `cloudfront-kvs-client.ts`'s throw a DEFAULT-level
325
- * line, and for THAT sub-path the text arrives pre-sanitized — but it is a
326
- * catch-all over the whole request pipeline, so an origin fetch, an
327
- * edge-function invoke or an SDK error raised anywhere below it still
328
- * reaches the line RAW.
329
- * - `ecs-service-emulator.ts`'s `logger.error` at three sites (`:909`,
330
- * `:1508`, `:1599`). One of them does carry
331
- * `ecs-secrets-resolver.ts`'s `EcsSecretsResolutionError`, which this
332
- * module sanitized on the way in — and reasoning from that to "the relay
333
- * is safe" is the SAME generalisation the bullet above rejects. All three
334
- * are catch-alls over a whole reload or replica roll, relaying
335
- * `err instanceof Error ? err.message : String(err)`, and what they
336
- * usually carry is docker CLI stderr, which is multi-line.
337
- *
338
- * So: one sub-path arriving pre-sanitized never clears a catch-all. Both need
339
- * the same per-occurrence call as everything else in this list. Neither is
340
- * done here — `cloudfront-server.ts` is out of this change's scope, and the
341
- * emulator's three are a different population (docker output, not SDK errors)
342
- * that wants the flattening half rather than the withholding half.
343
- *
344
- * # The two axes that decide it
345
- *
346
- * PROVENANCE alone says relay: a credential-chain error is about the
347
- * developer's own machine configuration, not something cdk-local fetched out
348
- * of a secret store (issue #555's criterion). Two further axes overrule it:
349
- *
350
- * LEVEL — these are `warn`, so they print on a plain `cdkl start-api` /
351
- * `cdkl invoke` run rather than only under `--verbose`, and `cdkl studio`
352
- * mirrors a serve child's output into a log ring it serves over HTTP.
353
- * Everybody sees a `warn`; only someone who asked sees a `debug`.
354
- *
355
- * RECONSTRUCTION — what can reach the string is not a hint about a secret,
356
- * it is the secret. `@aws-sdk/credential-provider-process` builds its
357
- * failure as `new CredentialsProviderError(error.message)` where `error` is
358
- * the rejection of `promisify(child_process.exec)`, i.e. Node's
359
- * `Command failed: <command line>\n<stderr>`, and a passphrase written on a
360
- * `credential_process` command line is an ordinary thing to have. (Verified
361
- * at `@aws-sdk/credential-provider-process@3.972.{39,41,43}`
362
- * `dist-cjs/index.js:56` in this repo's tree.)
363
- *
364
- * # Where these sites DIFFER from sigv4-verify's, and why the answer differs
365
- *
366
- * {@link file://./sigv4-verify.ts}'s `catch` wraps `client.config.credentials()`
367
- * and nothing else: credential-chain resolution, with no AWS operation
368
- * cdk-local asked for. Nothing actionable is lost by withholding all of it,
369
- * so that site withholds unconditionally and keeps the text at `debug`.
370
- *
371
- * The #570 sites wrap an SDK call cdk-local DID make — STS
372
- * `GetCallerIdentity` or `AssumeRole` — so two unrelated error populations
373
- * land in one `catch`:
374
- *
375
- * 1. the credential chain failed before the request went out, which is
376
- * #564's population exactly, and
377
- * 2. STS answered with a modeled service exception — `ExpiredTokenException`,
378
- * `AccessDenied` — whose message IS the diagnosis the user needs, and
379
- * which never went near `credential_process`. (Spelled as the SDK
380
- * spells them: `@aws-sdk/client-sts` models `ExpiredTokenException`
381
- * (`dist-cjs/models/errors.js:6`), while `AccessDenied` is unmodeled and
382
- * arrives as the wire code verbatim — which is exactly why the name is
383
- * clamped rather than trusted.)
384
- *
385
- * Blanket-withholding would turn the single commonest failure of these
386
- * commands (`ExpiredTokenException: The security token included in the
387
- * request is expired`) into a class name and a character count. So the split
388
- * is by population, via {@link describeAwsFailureForWarn}.
389
- *
390
- * # The safe state is defined POSITIVELY
391
- *
392
- * The discriminator is not a deny-list of bad error shapes — #564 rejected
393
- * that reasoning for the `Command failed:` first line, and it loses the same
394
- * race here. It is the SDK's own structural test for "this came off the wire
395
- * as a modeled service error" ({@link isAwsServiceException}). Anything that
396
- * does not match — a chain failure, a socket error, a bare `throw 'x'` — is
397
- * withheld. An unrecognised shape therefore fails toward withholding.
398
- *
399
- * A hostile endpoint cannot steer the discriminator into disclosing MORE.
400
- * `$fault` / `$metadata` are set by the SDK from the HTTP response, not from
401
- * anything the body names, so forging `x-amzn-errortype:
402
- * CredentialsProviderError` only changes `err.name` — the response is still a
403
- * service response, so the error stays on the KEPT branch and its message is
404
- * still the sanitized, capped one the endpoint could have sent under any
405
- * other name. What the endpoint cannot do is move a `credential_process`
406
- * command line onto that branch: that text originates locally, inside a
407
- * `CredentialsProviderError` that never acquires `$fault`.
408
- *
409
- * # What this does NOT close
410
- *
411
- * A single-LINE forged string still reaches readers other than a human. It no
412
- * longer redirects the studio capture proxy: issue #578 anchored every
413
- * `cdkl studio` ready pattern to the start of the line, made the serve manager
414
- * skip a line carrying cdk-local's own `WARN: ` / `ERROR: ` decoration
415
- * outright, and bounded the resolved upstream to loopback — so a message
416
- * containing `Server listening on http://...` is neither read as a banner nor
417
- * usable as a destination. What remains is the reader this sanitizer was
418
- * always aimed at: a HUMAN scanning the log, for whom a forged-looking line is
419
- * still misleading text, which is why the flattening + capping stay.
420
- */
421
- /**
422
- * Longest service-exception message relayed into a `warn` line, in
423
- * characters.
424
- *
425
- * A modeled STS error's message is WIRE-DERIVED — the SDK reads it out of the
426
- * response body — so its length is not cdk-local's to assume. Real ones run
427
- * to roughly 150 characters (`AccessDenied: User: arn:... is not authorized to
428
- * perform: sts:AssumeRole on resource: arn:...`), and the policy-quoting ones
429
- * are the longest, so the cap is set well above that: it exists to stop an
430
- * unbounded body from becoming an unbounded log line, not to trim ordinary
431
- * AWS text, which must survive intact or the branch buys nothing.
432
- *
433
- * Truncation is announced with the true length (see
434
- * {@link sanitizeServiceExceptionMessage}) so a capped line is never mistaken
435
- * for a complete one.
436
- */
437
- const SERVICE_MESSAGE_MAX = 512;
438
- /**
439
- * Total stringification of a thrown value's message.
440
- *
441
- * `String(err)` is NOT total. That throw would escape the `catch` it is
442
- * called from and turn a warn-and-continue branch into a hard failure — a 500
443
- * from the SigV4 authorizer in #564's testing, and a dead `cdkl invoke` here.
444
- * One helper rather than an expression repeated per site, so the length
445
- * reported in a `warn` and the text printed at `debug` can never describe
446
- * different strings.
447
- *
448
- * CORRECTION to #564, measured on this repo's Node 24: `String(aSymbol)`
449
- * does NOT throw — it returns `'Symbol(x)'`, and it is template
450
- * INTERPOLATION (`` `${aSymbol}` ``) that raises
451
- * `TypeError: Cannot convert a Symbol value to a string`. Since both the old
452
- * code and this helper call `String()` explicitly, a `Symbol` throw was never
453
- * one of the reachable cases. The two that ARE reachable, both verified:
454
- * an object with a null prototype (`TypeError: Cannot convert object to
455
- * primitive value`, because it has no `toString`), and any value whose
456
- * `toString` / `Symbol.toPrimitive` throws — which a hostile or merely buggy
457
- * third-party error object is free to do.
458
- *
459
- * Named for what it does rather than for one caller: #564 shipped it as
460
- * `stringifyCredentialLoadFailure`, and #570 gave it a second population
461
- * (service exceptions) for which that name was simply wrong.
462
- */
463
- function stringifyThrown(err) {
464
- try {
465
- return err instanceof Error ? String(err.message) : String(err);
466
- } catch {
467
- return "[unstringifiable throw]";
468
- }
469
- }
470
- /**
471
- * The throwing class's name, clamped to something no input can forge.
472
- *
473
- * `err.name` is NOT input-independent, which is the whole reason this is a
474
- * function and not an interpolation. For an AWS service exception it is
475
- * WIRE-DERIVED: `@aws-sdk/core` builds it from the `x-amzn-errortype` header
476
- * / the body's `code` / `__type` through `sanitizeErrorCode`, which splits on
477
- * ',' ':' and '#' and does nothing else — no length cap, no newline stripping
478
- * (read at `@aws-sdk/core@3.974.13` `protocols/index.js:324`). Such an
479
- * exception reaches these call sites, because `credential-provider-ini` calls
480
- * the STS `roleAssumer` unwrapped and because the #570 sites invoke STS
481
- * directly. So a hostile or hijacked credential endpoint
482
- * (`AWS_CONTAINER_CREDENTIALS_FULL_URI`, a redirected IMDS) answering
483
- * `x-amzn-errortype: Foo\nWARN: signature verified` could FORGE log lines
484
- * into the `warn` stream — and into the studio ring served over HTTP.
485
- *
486
- * A bare identifier of at most 64 characters is what every real class name is
487
- * and what no injected value can be; anything else degrades to `'unknown'`,
488
- * which also covers a non-`Error` throw, so the field never names a class the
489
- * throw was not.
490
- *
491
- * Note the WRONG fix: `err.name.slice(0, 64)` keeps the newline, so the
492
- * forgery survives truncation.
493
- */
494
- function clampErrorName(err) {
495
- let rawKind;
496
- try {
497
- rawKind = err instanceof Error ? err.name : "unknown";
498
- } catch {
499
- return "unknown";
500
- }
501
- return typeof rawKind === "string" && /^[A-Za-z0-9_.-]{1,64}$/.test(rawKind) ? rawKind : "unknown";
502
- }
503
- /**
504
- * The thrown value's `code`, when it is a bare identifier, else `undefined`.
505
- *
506
- * This exists because withholding by class name alone is not discriminating
507
- * enough for the population that actually reaches the withheld branch most
508
- * often. `Region is missing`, `getaddrinfo ENOTFOUND sts.<region>.amazonaws.com`
509
- * and `connect ETIMEDOUT 169.254.169.254:80` all arrive as a plain `Error`, so
510
- * all three render as `Error; N-character message withheld` and the reader
511
- * cannot tell a misconfiguration from an unreachable endpoint.
512
- *
513
- * Node sets `code` on its system errors (`ENOTFOUND`, `ETIMEDOUT`,
514
- * `ECONNREFUSED`) and there it is a fixed enum chosen by the runtime. It is
515
- * NOT input-independent in general, though, and the earlier draft of this note
516
- * claimed it was: `@smithy/core`'s `decorateServiceException`
517
- * (`dist-cjs/submodules/client/index.js:795-802`) copies every key of the
518
- * PARSED RESPONSE BODY onto the exception, so an unmodeled error from a
519
- * hostile endpoint can carry a `code` of its choosing. The clamp is therefore
520
- * load-bearing rather than belt-and-braces, exactly as it is for
521
- * {@link clampErrorName}: what survives is at most 64 characters matching
522
- * `[A-Za-z0-9_.-]`, which cannot forge a line, and which is the same residue
523
- * the class name already accepts.
524
- *
525
- * `undefined` rather than `'unknown'` when there is none, so the caller can
526
- * omit the field entirely instead of printing a placeholder that says less
527
- * than nothing.
528
- */
529
- function clampErrorCode(err) {
530
- if (!err || typeof err !== "object") return void 0;
531
- let raw;
532
- try {
533
- raw = err.code;
534
- } catch {
535
- return;
536
- }
537
- if (typeof raw !== "string") return void 0;
538
- return /^[A-Za-z0-9_.-]{1,64}$/.test(raw) ? raw : void 0;
539
- }
540
- /**
541
- * Is `err` a modeled AWS service exception — i.e. did the SDK parse it out of
542
- * a service RESPONSE, rather than raise it while assembling the request?
543
- *
544
- * This is `ServiceException.isInstance`'s own structural test, reproduced
545
- * rather than imported: cdk-local loads `@aws-sdk/client-sts` lazily at every
546
- * one of these call sites (a dynamic `import()` inside the `try`), so taking
547
- * a static dependency on `@smithy/core` purely to type-test an error would
548
- * pull the SDK into the CLI's startup path. The structural form also keeps
549
- * working across the several `@smithy/core` copies pnpm resolves in this
550
- * tree, where an `instanceof` against one copy's class fails for an error
551
- * minted by another.
552
- *
553
- * The test is deliberately the SDK's and not a name list: a name list would
554
- * have to be extended for every new STS error code, and a missing entry would
555
- * fail toward DISCLOSING less, which is safe, but also toward hiding the
556
- * actionable message, which is the regression this split exists to avoid.
557
- */
558
- function isAwsServiceException(err) {
559
- if (!err || typeof err !== "object") return false;
560
- const candidate = err;
561
- try {
562
- return Boolean(candidate.$metadata) && (candidate.$fault === "client" || candidate.$fault === "server");
563
- } catch {
564
- return false;
565
- }
566
- }
567
- /**
568
- * Replace every character that could make one emitted line render as more than
569
- * one line, or render in an order it was not written in, with a space.
570
- *
571
- * Four Unicode categories, each measured rather than assumed (see the fixture
572
- * in `tests/unit/local/credential-error.test.ts`):
573
- *
574
- * - `Cc` — the C0/C1 controls, which is `\n` / `\r` (a forged extra line in
575
- * the studio ring) and `\x1b` (an ANSI escape in a terminal). U+0085 NEL
576
- * lives here too.
577
- * - `Cf` — the format characters, which is U+202E RIGHT-TO-LEFT OVERRIDE and
578
- * friends: they forge how the REST of the line reads. The cost is that a
579
- * ZWJ inside an emoji or an Indic cluster is replaced too; an AWS error
580
- * message is ASCII, so that is a trade with no observed downside.
581
- * - `Zl` / `Zp` — U+2028 and U+2029. Neither is in `Cc` or `Cf`, and both are
582
- * forced line breaks in the studio UI's `<pre>`, so leaving them out would
583
- * have left the forged-line case open in HTML while closing it in a
584
- * terminal.
585
- * - `Cs` — a LONE surrogate, which is not a character at all and breaks JSON
586
- * encoding of the log event. With the `u` flag a well-formed pair is one
587
- * code point and does NOT match, so emoji survive.
588
- *
589
- * Used by both branches of {@link describeAwsFailureForWarn} — the kept
590
- * message, and the `debug` line carrying the withheld one, because the `debug`
591
- * stream is the SAME studio ring under `--verbose`, so a text unsafe to put on
592
- * a line at `warn` is unsafe at `debug` — and, since issue #570's review
593
- * rounds, by every OTHER wire-derived value that lands on one of these lines:
594
- * `sigv4-verify`'s own `debug` line, and the role ARN at the four warn sites
595
- * plus the five `info` / `debug` sites that print an ARN resolved from a live
596
- * `GetFunctionConfiguration` / `GetAgentRuntime` response. The last group is
597
- * the more reachable one — it fires on SUCCESS.
598
- */
599
- function flattenToOneLine(message) {
600
- return message.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/gu, " ");
601
- }
602
- /**
603
- * Render a wire-derived service message safe to put on one log line.
604
- *
605
- * Two properties, and both are about the LINE rather than about secrecy — the
606
- * message itself is already judged safe to print by the time this is called:
607
- *
608
- * - No line breaks and no rendering-direction control, via
609
- * {@link flattenToOneLine}.
610
- * - Bounded length, with the true length named when it is exceeded. The
611
- * suffix names the character count of the SANITIZED message rather than
612
- * of the raw one, because the sanitized string is what the truncated
613
- * prefix is a prefix OF; quoting the raw length would describe a string
614
- * the reader can never see.
615
- *
616
- * The cut is made on CODE POINTS rather than UTF-16 units, so a truncation
617
- * landing inside an astral character cannot emit half of a surrogate pair. The
618
- * count in the suffix is the code-point count for the same reason: the prefix
619
- * and the number must describe the same string.
620
- */
621
- function sanitizeServiceExceptionMessage(message) {
622
- const points = [...flattenToOneLine(message)];
623
- if (points.length <= SERVICE_MESSAGE_MAX) return points.join("");
624
- return `${points.slice(0, SERVICE_MESSAGE_MAX).join("")}[... truncated; ${points.length}-character message]`;
625
- }
626
- /**
627
- * Describe a credential-chain failure for a default-level log line WITHOUT
628
- * relaying the chain's own message (issue #564).
629
- *
630
- * What is reported instead is input-INDEPENDENT: the clamped class name (see
631
- * {@link clampErrorName}), which is the same discriminator
632
- * `ecs-secrets-resolver.ts` reports for the JSON parse failure it must not
633
- * echo, plus the clamped {@link clampErrorCode} when the throw carries one.
634
- *
635
- * Withholding is NOT free, and the cost lands on cdk-local's own text as well
636
- * as on the SDK's. The worked example used to be `role-arn.ts`'s
637
- * `AssumeRole(<arn>) returned no usable credentials.`: a plain `Error` with no
638
- * `$fault`, so it was withheld like any other, and the note recorded that as a
639
- * real diagnostic loss accepted because the alternative — an allow-list of
640
- * messages that may print — is the deny-list this design rejects, wearing the
641
- * other sign. It also named the right fix: make cdk-local's own throws
642
- * IDENTIFIABLE rather than guess at their text.
643
- *
644
- * Issue #579 did that, for exactly this example. `role-arn.ts` now throws
645
- * `AssumeRoleFailure`, carrying a `detail` its relaying callers print verbatim
646
- * — and the same pattern closed the identical loss in
647
- * `layer-arn-materializer.ts`, `httpv2-service-integration.ts`,
648
- * `ecr-puller.ts`, `local-run-task.ts` and `ecs-service-emulator.ts`. The
649
- * general point stands and is what a new site should apply: the policy cannot
650
- * tell cdk-local's own sentence from a hostile endpoint's, so a `catch` that
651
- * can see both must make its own throws recognisable BEFORE the relay.
652
- *
653
- * The remaining ARN-length question — an ARN is structured, so the bound
654
- * belongs at the three resolution points rather than at the log line — is
655
- * tracked separately at
656
- * https://github.com/go-to-k/cdk-local/issues/607.
657
- *
658
- * The LENGTH is reported, unlike in `ecs-secrets-resolver.ts`, and the
659
- * difference is deliberate: there the user already knows which secret it is
660
- * and can read the value at its source, so a character count would be
661
- * disclosure buying nothing. Here the user cannot see the withheld message at
662
- * all, and the count is what separates a one-line
663
- * `Could not load credentials from any providers` (45 characters, measured
664
- * against `@aws-sdk/credential-provider-node@3.972.44` `dist-cjs/index.js:144`)
665
- * from a multi-hundred-character `Command failed:` dump — which is what tells
666
- * them whether their `credential_process` even ran.
667
- *
668
- * The count is a side channel, and a narrow one: against a KNOWN
669
- * `credential_process` template the number is `constant + len(passphrase)`, so
670
- * it yields the passphrase's LENGTH. That is accepted rather than overlooked.
671
- * It buys the one thing the user cannot otherwise see, the `Command failed:`
672
- * dump is not reachable on today's SDK versions anyway, and bucketing the
673
- * number would blur exactly the boilerplate-vs-dump distinction the field
674
- * exists for.
675
- */
676
- function describeCredentialLoadFailure(err) {
677
- const code = clampErrorCode(err);
678
- return `${code === void 0 ? clampErrorName(err) : `${clampErrorName(err)} ${code}`}; ${stringifyThrown(err).length}-character message withheld, logged at debug level under --verbose`;
679
- }
680
- /**
681
- * Render an AWS SDK call failure for a `logger.warn` line, and emit the
682
- * withheld text at `debug` when it is withheld (issue #570).
683
- *
684
- * `operation` names the call for the `debug` line — e.g.
685
- * `'STS GetCallerIdentity'`. It is cdk-local's own literal at every call
686
- * site, never anything read off an error or a response.
687
- *
688
- * # Why this emits the `debug` line itself
689
- *
690
- * Because the alternative is nine call sites each free to withhold a message
691
- * and forget to print it anywhere. The pairing is the invariant — "withheld
692
- * at `warn`" is only acceptable while "in full at `debug`" holds — so it
693
- * lives in one function rather than in a convention nine sites must keep. A
694
- * tenth site gets it for free.
695
- *
696
- * The `debug` line fires ONLY on the withheld branch. On the service-exception
697
- * branch the `warn` already carries the message, and repeating it verbatim one
698
- * level down would say nothing the reader did not just read.
699
- *
700
- * It is FLATTENED (not capped). Flattened because the `debug` stream is not a
701
- * private channel: it is the same stdout `cdkl studio` mirrors into the log
702
- * ring it serves over HTTP, so a `\n` in the withheld text would forge a line
703
- * there exactly as it would at `warn`. Not capped, because being able to read
704
- * the whole withheld message is the entire reason the line exists — the
705
- * `warn`'s character count is what tells the reader how much they are about to
706
- * see. Note that this puts the full text into that ring for a
707
- * `--verbose` studio run; see `docs/troubleshooting.md`.
708
- *
709
- * ORDERING: the `debug` line is emitted when THIS function runs, which is
710
- * before the `warn` at every site — inline in the `warn`'s template at eight of
711
- * them, and a couple of statements earlier at the one that hoists the result
712
- * into a `const reason`. Either way it prints immediately ABOVE the `warn` that
713
- * refers to it under `--verbose`. Left as is: restructuring every call site to
714
- * log afterwards would buy an ordering nobody reads top-down anyway.
715
- */
716
- function describeAwsFailureForWarn(err, operation) {
717
- if (isAwsServiceException(err)) return `${clampErrorName(err)}: ${sanitizeServiceExceptionMessage(stringifyThrown(err))}`;
718
- getLogger().debug(`${operation}: the AWS SDK's own failure message was: ${flattenToOneLine(stringifyThrown(err))}`);
719
- return describeCredentialLoadFailure(err);
720
- }
721
-
722
- //#endregion
723
- //#region src/utils/assembly-path.ts
724
- /**
725
- * `true` when `candidate` names something strictly beneath `base`, both given
726
- * as already-resolved absolute paths.
727
- *
728
- * The `..` test is SEPARATOR-AWARE rather than a bare `startsWith('..')`,
729
- * which would also reject a legitimate sibling named `..foo`. The empty-string
730
- * case is `base` itself; callers decide whether that counts as an escape,
731
- * because an asset path legitimately names a DIRECTORY while a template path
732
- * does not.
733
- */
734
- function isInside(base, candidate) {
735
- const rel = relative(base, candidate);
736
- if (rel === "" || rel === "..") return false;
737
- if (rel.startsWith(`..${sep}`)) return false;
738
- return !isAbsolute(rel);
739
- }
740
- /**
741
- * `realpath(3)`, or `undefined` for a path that does not fully resolve.
742
- *
743
- * `.native` is load-bearing and must not be simplified to `fs.realpathSync`.
744
- * The plain form is a JavaScript walker that folds `..` LEXICALLY, so it
745
- * disagrees with the kernel on a target carrying a `..` after a symlinked
746
- * component — answering `ENOENT` for a path the kernel resolves, and on a
747
- * case-insensitive filesystem (APFS by default) it can spin on the same shape.
748
- * `.native` is libuv's `realpath(3)` and throws the same
749
- * `ENOENT` / `ELOOP` / `EACCES`.
750
- */
751
- function tryRealpath(p) {
752
- try {
753
- return realpathSync.native(p);
754
- } catch {
755
- return;
756
- }
757
- }
758
- /** `fs.readlinkSync`, or `undefined` when `p` is not a symbolic link. */
759
- function tryReadlink(p) {
760
- try {
761
- return readlinkSync(p);
762
- } catch {
763
- return;
764
- }
765
- }
766
- /**
767
- * Link follows before the walk gives up and reports the path as unresolvable.
768
- * It does NOT refuse: an exhausted budget answers `undefined`, which leaves
769
- * the symlink arm silent and the lexical verdict standing. Safe because the OS
770
- * gives up first (macOS caps at 32, `ELOOP`), so a chain that reaches this cap
771
- * is one nothing can open anyway.
772
- */
773
- const MAX_LINK_HOPS = 40;
774
- /**
775
- * Unresolvable path COMPONENTS the climb walks before giving up. Bounds the
776
- * recursion below, which is one frame per component, so a pathological value
777
- * cannot raise a `RangeError` from inside {@link tryRealpath}'s own `try` (its
778
- * `catch` would swallow the crash into a silent `undefined`).
779
- *
780
- * KNOW THAT EXHAUSTING THIS FAILS OPEN, and why that is accepted rather than
781
- * unnoticed. Unlike {@link MAX_LINK_HOPS}, which has the OS's own `ELOOP` cap
782
- * behind it, nothing else stops a path of 1 001 absent components: the climb
783
- * gives up, the symlink arm goes silent, and a value that would land outside
784
- * reads as CONTAINED on the lexical verdict alone. It is not reachable in
785
- * effect — such a path cannot exist, so `cdkl invoke`'s `existsSync` refuses it
786
- * and `start-api`'s `docker run` fails to mount it — but that safety lives in
787
- * the CONSUMERS, not here. A consumer that neither checks existence nor mounts
788
- * would need its own answer.
789
- */
790
- const MAX_PATH_COMPONENTS = 1e3;
791
- /**
792
- * Where `target` REALLY points, for a path that may not exist yet.
793
- *
794
- * `realpathSync` answers only for a path that fully resolves, and it throws
795
- * `ENOENT` for a DANGLING symbolic link exactly as it does for an absent file.
796
- * `cdkl start-api` resolves an asset path without an existence check, so an
797
- * absent or dangling component must not silence the symlink arm.
798
- *
799
- * Each unresolvable component is therefore handled by hand: climb to the
800
- * deepest ancestor that DOES resolve, then re-apply the remaining components,
801
- * following any symbolic link with `readlink` and re-resolving its target.
802
- * `undefined` means the walk could not resolve the path at all.
803
- *
804
- * For a path that FULLY RESOLVES the answer is the kernel's and is exact. For
805
- * one that does not exist yet this is a best-effort MODEL of kernel
806
- * resolution; the known edge is a `..` INSIDE an unresolvable link's target,
807
- * folded lexically here while the kernel folds it only after following each
808
- * preceding component. That edge is benign for THESE callers rather than in
809
- * general: it needs the target to be absent, and both consumers resolve the
810
- * path and then open or mount it, so the divergence is between this model and
811
- * a path nothing can reach. A caller that CREATED a file through such a path
812
- * would need an `lstat` of its own.
813
- *
814
- * `EACCES` is folded into "does not resolve" along with `ENOENT`, so a link
815
- * under a directory this process cannot traverse reads as contained. Same
816
- * bound: the consumer's own `existsSync` / mount runs as the same user and
817
- * fails identically.
818
- */
819
- function resolveThroughLinks(target, hops = 0, climbs = 0) {
820
- const direct = tryRealpath(target);
821
- if (direct !== void 0) return direct;
822
- if (hops >= MAX_LINK_HOPS) return void 0;
823
- if (climbs >= MAX_PATH_COMPONENTS) return void 0;
824
- const parent = dirname(target);
825
- if (parent === target) return void 0;
826
- const realParent = resolveThroughLinks(parent, hops, climbs + 1);
827
- if (realParent === void 0) return void 0;
828
- const link = tryReadlink(target);
829
- if (link === void 0) return join(realParent, basename(target));
830
- return resolveThroughLinks(resolve(realParent, link), hops + 1, climbs);
831
- }
832
- /**
833
- * Resolve an assembly-supplied `candidate` against `dir` and report whether
834
- * the result stays inside it.
835
- *
836
- * The lexical arm joins exactly the way the call sites used to (`path.join`,
837
- * NOT `path.resolve`), so the verdict is about the path the caller will
838
- * actually use. `join`'s handling of an absolute candidate makes this arm
839
- * strictly more permissive than a `resolve`-based one would be; an absolute
840
- * value is therefore answered by {@link absoluteAssemblyPathEscape} instead,
841
- * because this function structurally cannot.
842
- *
843
- * The SYMLINK arm exists because the lexical arm alone leaves an equivalent
844
- * hole: `cdk.out/link -> /etc` plus a candidate of `link/passwd` is lexically
845
- * contained and still reaches `/etc/passwd`. Both sides go through
846
- * {@link resolveThroughLinks}, so an assembly directory REACHED through a link
847
- * (macOS spells `/tmp` as `/private/tmp`) is unaffected.
848
- *
849
- * The verdict is about the assembly AS IT SITS ON DISK. It is NOT a defence
850
- * against a process rewriting that directory concurrently — the caller uses
851
- * the path after this returns.
852
- */
853
- function resolveAssemblyPath(dir, candidate, options) {
854
- const base = resolve(dir);
855
- const bound = options?.containWithin === void 0 ? base : resolve(options.containWithin);
856
- const joined = resolve(join(base, candidate));
857
- if (!isInside(bound, joined)) return {
858
- contained: false,
859
- escape: "lexical",
860
- path: joined
861
- };
862
- const realBound = resolveThroughLinks(bound);
863
- if (realBound !== void 0) {
864
- const realTarget = resolveThroughLinks(joined);
865
- if (realTarget !== void 0 && !isInside(realBound, realTarget)) return {
866
- contained: false,
867
- escape: "symlink",
868
- path: joined,
869
- realPath: realTarget
870
- };
871
- }
872
- return {
873
- contained: true,
874
- path: joined
875
- };
876
- }
877
- /**
878
- * Whether an ALREADY-ABSOLUTE assembly-supplied path lies outside `bound`.
879
- *
880
- * {@link resolveAssemblyPath} cannot answer this, and the reason is structural
881
- * rather than an oversight: its lexical arm joins with `path.join`, which does
882
- * NOT honour a leading separator, so an absolute candidate is folded INTO the
883
- * directory and the verdict would describe a path no caller opens. A site that
884
- * HONOURS an absolute value needs the verdict about the value itself.
885
- *
886
- * The callers are `cdkl invoke`'s and `cdkl start-api`'s
887
- * `Metadata['aws:asset:path']` resolution. Those honour an absolute path
888
- * because `cdk synth --no-staging` emits one — CDK writes the asset's absolute
889
- * SOURCE directory under `aws:cdk:disable-asset-staging`, usually outside the
890
- * outdir — and they WARN rather than refuse when it leaves the bound, so this
891
- * returns a verdict rather than throwing. The asset-MANIFEST readers that
892
- * honour an absolute value take the same verdict: `'honour-warn'` (the
893
- * start-cloudfront origin) warns when the value is a folder inside the user's
894
- * project not under a credential or version-control directory, and refuses
895
- * otherwise, `'honour'` (the soft-reload
896
- * sources) refuses (#745).
897
- *
898
- * It lives HERE so the containment rule has one spelling: it reuses this
899
- * module's own {@link isInside} and {@link resolveThroughLinks}, symlink arm
900
- * included, rather than letting a caller re-spell `path.relative` and drift.
901
- *
902
- * `bound` itself is NOT an escape, unlike in {@link resolveAssemblyPath},
903
- * where an empty `path.relative` means "names the directory rather than a file
904
- * inside it". An asset path legitimately names a DIRECTORY, so a value equal
905
- * to the bound is inside it and reporting it as outside would be false.
906
- *
907
- * THE REAL PATHS DECIDE IN BOTH DIRECTIONS, which is the one place this
908
- * deliberately does more than {@link resolveAssemblyPath}'s lexical-first
909
- * ordering. The lexical verdict here is about a SPELLING, and two spellings of
910
- * the same directory are common rather than exotic: macOS resolves `/tmp` to
911
- * `/private/tmp` and `/var` to `/private/var`, and a user may symlink `cdk.out`
912
- * itself. With an outdir of `/tmp/cdk.out` and a `--no-staging` value of
913
- * `/private/tmp/cdk.out/src`, a lexical-only verdict cries "pointing outside
914
- * the assembly ... treat this assembly as untrusted" about an asset that is
915
- * plainly inside it — and this arm exists to make an UNEXPECTED path visible,
916
- * so a false alarm is the failure that costs it its meaning.
917
- *
918
- * Exonerating requires the kernel to answer for BOTH operands: an unresolvable
919
- * one leaves the lexical verdict standing, so the arm stays loud when it cannot
920
- * see. (`resolveAssemblyPath` cannot take the same shape: its verdict is about
921
- * a path built with `path.join`, and letting a real path overrule the lexical
922
- * `..` there would answer about a location the caller never opens.)
923
- */
924
- function absoluteAssemblyPathEscape(bound, absolutePath) {
925
- const resolvedBound = resolve(bound);
926
- const target = resolve(absolutePath);
927
- const lexicallyOutside = !isInside(resolvedBound, target) && target !== resolvedBound;
928
- const realBound = resolveThroughLinks(resolvedBound);
929
- const realTarget = resolveThroughLinks(target);
930
- const reallyOutside = realBound === void 0 || realTarget === void 0 ? void 0 : !isInside(realBound, realTarget) && realTarget !== realBound;
931
- if (lexicallyOutside) {
932
- if (reallyOutside === false) return void 0;
933
- return {
934
- contained: false,
935
- escape: "lexical",
936
- path: target
937
- };
938
- }
939
- if (reallyOutside === true) return {
940
- contained: false,
941
- escape: "symlink",
942
- path: target,
943
- realPath: realTarget
944
- };
945
- }
946
- /**
947
- * Whether `candidate` names the SAME directory as `bound` — lexically, or
948
- * through a symbolic link on either side.
949
- *
950
- * An asset source legitimately names a DIRECTORY, so a value that resolves
951
- * onto the output directory itself is not an escape; but it hands the WHOLE
952
- * assembly to the sink, which is worth a line. A caller that re-spells this as
953
- * `resolve(a) === resolve(b)` misses every second spelling of the bound
954
- * (macOS `/tmp` vs `/private/tmp`, a symlinked `cdk.out`), and
955
- * {@link absoluteAssemblyPathEscape} exonerates exactly those spellings as
956
- * inside — so the equality beside it would stay silent for them.
957
- *
958
- * Conservative on failure: an unresolvable side answers from the lexical
959
- * comparison alone, so it can only say "not the same", never wrongly claim
960
- * identity.
961
- */
962
- function namesTheSameDirectory(bound, candidate) {
963
- const resolvedBound = resolve(bound);
964
- const target = resolve(candidate);
965
- if (target === resolvedBound) return true;
966
- const realBound = resolveThroughLinks(resolvedBound);
967
- const realTarget = resolveThroughLinks(target);
968
- return realBound !== void 0 && realTarget !== void 0 && realBound === realTarget;
969
- }
970
- /**
971
- * The escape verdict for an assembly-supplied path WITHOUT throwing, taking
972
- * whichever arm the value's own shape calls for: {@link absoluteAssemblyPathEscape}
973
- * for an absolute value, {@link resolveAssemblyPath} for a relative one.
974
- *
975
- * For a caller that only WARNS and has many values to judge — the BuildKit
976
- * passthroughs in `src/assets/buildkit-passthrough-warnings.ts`. `base` is what
977
- * a relative value resolves against, `bound` what it must stay inside.
978
- * Returns `undefined` when the value is fine, including when it names `bound`
979
- * itself ({@link namesTheSameDirectory}).
980
- */
981
- function assemblyPathEscape(base, bound, candidate) {
982
- if (isAbsolute(candidate)) return absoluteAssemblyPathEscape(bound, candidate);
983
- const resolved = resolveAssemblyPath(base, candidate, { containWithin: bound });
984
- if (resolved.contained || namesTheSameDirectory(bound, resolved.path)) return void 0;
985
- return resolved;
986
- }
987
- /**
988
- * The shared tail of a containment refusal: what the value resolved to, what
989
- * it escaped, and why that means the assembly is not CDK-generated. The call
990
- * site supplies its own subject ("Lambda 'X' has ... which ") and its own
991
- * error class. `action` completes "Refusing to ..." and is REQUIRED rather
992
- * than defaulted: a default is a branch no caller takes, so it can neither be
993
- * fenced nor be right for the next caller.
994
- *
995
- * `provenanceOverride` replaces that whole closing sentence for a caller that
996
- * does NOT refuse — the BuildKit passthrough warnings, where "Refusing to ..."
997
- * would be a false statement about a value that is forwarded anyway.
998
- */
999
- function renderAssemblyPathEscape(escape, dir, action, provenanceOverride) {
1000
- const provenance = provenanceOverride ?? `CDK emits assembly paths that stay inside the assembly directory; one that leaves it indicates the synth output was hand-modified or generated by a non-CDK toolchain. Refusing to ${action}.`;
1001
- const base = resolve(dir);
1002
- const realBase = resolveThroughLinks(base) ?? base;
1003
- const shownPath = displayUntrustedValue(escape.path);
1004
- const shownBase = displayUntrustedValue(base);
1005
- if (escape.escape === "symlink") {
1006
- if (escape.realPath === realBase) return `resolves to ${shownPath}, a symbolic link to the directory ${shownBase} itself rather than to a path inside it. ${provenance}`;
1007
- return `resolves to ${shownPath}, which leads through a symbolic link to ${displayUntrustedValue(escape.realPath)}, outside ${shownBase}. ${provenance}`;
1008
- }
1009
- if (escape.path === base) return `names the directory ${shownBase} itself rather than a path inside it. ${provenance}`;
1010
- return `resolves to ${shownPath}, outside ${shownBase}. ${provenance}`;
1011
- }
1012
- /**
1013
- * A letter or digit that neither draws as a blank nor reads as a quote.
1014
- * `\p{L}` alone admits both, so two classes are carved out: the
1015
- * default-ignorables, which hold letters that draw as a blank (the Hangul
1016
- * fillers U+3164 and U+FFA0), and the quote-shaped letters — the Spacing
1017
- * Modifier Letters block (U+02BA reads as `"`, U+02BC as `'`) plus the ones
1018
- * outside it (U+0374, U+0559, U+07F4-U+07F5, U+A78B-U+A78C, and the halfwidth
1019
- * sound marks U+FF9E-U+FF9F). Not all of `\p{Lm}`: U+30FC is in it, and it is
1020
- * an ordinary character of a Japanese directory name.
1021
- */
1022
- const VISIBLE_LETTER = new RegExp(String.raw`^(?![\p{Default_Ignorable_Code_Point}\u02b0-\u02ff\u0374\u0559\u07f4\u07f5\ua78b\ua78c\uff9e\uff9f])[\p{L}\p{N}]$`, "u");
1023
- /**
1024
- * A combining mark, which counts only DIRECTLY after a visible letter (or a
1025
- * mark that itself counted): on a space or on punctuation it draws on its own,
1026
- * and U+030B / U+030E there look like a quote.
1027
- */
1028
- const COMBINING_MARK = /^\p{M}$/u;
1029
- /**
1030
- * A default-ignorable code point, which draws as NOTHING. Tested before the
1031
- * mark rule, because some are combining marks (U+034F, U+17B4, U+180B, the
1032
- * variation selectors U+FE00-U+FE0F and U+E0100-U+E01EF): after a letter they
1033
- * would otherwise pass as bare, and `/home/me/.ss\u034fh` would print exactly
1034
- * like `.ssh` while naming a different path. The host's classifier
1035
- * (go-to-k/cdkd#3509) does not carve these out; this one does.
1036
- */
1037
- const DEFAULT_IGNORABLE = /^\p{Default_Ignorable_Code_Point}$/u;
1038
- /**
1039
- * The ASCII a bare value may carry besides letters and digits. Everything else
1040
- * — any whitespace, any quote, any other symbol — takes the boundary, which
1041
- * costs a legitimate path nothing but a pair of quotes.
1042
- */
1043
- const BARE_PUNCTUATION = /^[/\\._~+@:=-]$/;
1044
- /**
1045
- * Classify each code point: `bare` (allowed in a bare value), `shown` (shown
1046
- * as itself inside the boundary), or `escaped`. An ALLOWLIST, because a
1047
- * denylist has to enumerate every character that draws as a blank or reads as
1048
- * a quote.
1049
- *
1050
- * Each test is ONE character and this walks the value — never `^(...)+$` over
1051
- * the whole of it: alternatives that overlap under a quantifier backtrack
1052
- * exponentially on a long value that fails near its end.
1053
- */
1054
- function classify(chars) {
1055
- let afterLetter = false;
1056
- return chars.map((ch) => {
1057
- if (DEFAULT_IGNORABLE.test(ch)) {
1058
- afterLetter = false;
1059
- return "escaped";
1060
- }
1061
- if (COMBINING_MARK.test(ch)) return afterLetter ? "bare" : "escaped";
1062
- afterLetter = VISIBLE_LETTER.test(ch);
1063
- if (afterLetter || BARE_PUNCTUATION.test(ch)) return "bare";
1064
- return ch >= " " && ch <= "~" ? "shown" : "escaped";
1065
- });
1066
- }
1067
- /**
1068
- * Render an untrusted value — a filesystem path, identifier or command an
1069
- * assembly chose, or a key a request supplied — into cdk-local's own prose
1070
- * (go-to-k/cdk-local#758; the host's `displayAssemblyPath`, go-to-k/cdkd#3509).
1071
- * The caller writes NO quotes around the result.
1072
- *
1073
- * `sanitizeServiceExceptionMessage` alone is not enough inside quotes of ours:
1074
- * it flattens control characters and passes `'`, so a value carrying one
1075
- * closed the quote and wrote a clause of its own into the message. This keeps
1076
- * a plain value bare and puts any other one inside a JSON string literal.
1077
- * Inside it, `"` and `\` are escaped as JSON escapes them, and so is every
1078
- * character that is neither printable ASCII nor a visible letter — a curly or
1079
- * fullwidth quote that could pass for the boundary's own closing `"`, and a
1080
- * blank that could pass for a space. The result stays valid JSON: `JSON.parse`
1081
- * returns the sanitized value.
1082
- *
1083
- * A legitimate value with a space or any symbol outside `/ \ . _ ~ + @ : = -`
1084
- * (`/Users/me/My Project/cdk.out`) renders quoted; a non-ASCII letter is
1085
- * shown as itself, and a non-letter non-ASCII character (an emoji, `©`) as
1086
- * its `\u` escape. A value the sanitizer ALTERED (a control character
1087
- * flattened, an over-long value truncated) did not arrive plain, so it always
1088
- * takes the boundary. The empty string renders as `""`.
1089
- *
1090
- * The sanitizer also CAPS the value at 512 code points, so a longer path
1091
- * prints truncated (inside the boundary, with its true length named). That
1092
- * is deliberate: an unbounded assembly-chosen value would otherwise make the
1093
- * log line as long as the attacker likes, and a real path is far shorter.
1094
- */
1095
- function displayUntrustedValue(value) {
1096
- const clean = sanitizeServiceExceptionMessage(value);
1097
- const chars = Array.from(clean);
1098
- const kinds = classify(chars);
1099
- if (clean === value && chars.length > 0 && kinds.every((k) => k === "bare")) return clean;
1100
- let body = "";
1101
- chars.forEach((ch, i) => {
1102
- if (ch === "\"" || ch === "\\") body += `\\${ch}`;
1103
- else if (kinds[i] !== "escaped") body += ch;
1104
- else for (let u = 0; u < ch.length; u++) body += `\\u${ch.charCodeAt(u).toString(16).padStart(4, "0")}`;
1105
- });
1106
- return `"${body}"`;
1107
- }
1108
-
1109
- //#endregion
1110
- //#region src/utils/docker-cmd.ts
1111
- var docker_cmd_exports = /* @__PURE__ */ __exportAll({
1112
- DOCKER_CLIENT_ENV_KEYS: () => DOCKER_CLIENT_ENV_KEYS,
1113
- DOCKER_CLIENT_ENV_PREFIXES: () => DOCKER_CLIENT_ENV_PREFIXES,
1114
- DOCKER_CLIENT_ENV_PREFIX_EXEMPTIONS: () => DOCKER_CLIENT_ENV_PREFIX_EXEMPTIONS,
1115
- allowSecretsOnArgvEnvName: () => allowSecretsOnArgvEnvName,
1116
- finchSecretArgvRefusal: () => finchSecretArgvRefusal,
1117
- formatDockerLoginError: () => formatDockerLoginError,
1118
- getDockerCmd: () => getDockerCmd,
1119
- isDockerClientEnvKey: () => isDockerClientEnvKey,
1120
- isFinchVmClient: () => isFinchVmClient,
1121
- isMalformedEnvKey: () => isMalformedEnvKey,
1122
- runDockerForeground: () => runDockerForeground,
1123
- runDockerStreaming: () => runDockerStreaming,
1124
- spawnForeground: () => spawnForeground,
1125
- spawnStreaming: () => spawnStreaming,
1126
- warnFinchArgvExposure: () => warnFinchArgvExposure
1127
- });
1128
- /**
1129
- * Shared helpers for invoking the docker-compatible CLI binary across cdk-local.
1130
- *
1131
- * Two parity decisions with `aws-cdk-cli`'s `cdk-assets-lib`:
1132
- * 1. `CDK_DOCKER` env var swaps the binary so podman / finch users can
1133
- * run cdk-local without code changes (`CDK_DOCKER=podman cdkl invoke`).
1134
- * 2. `runDockerStreaming` uses streaming spawn rather than `execFile`'s
1135
- * buffered `maxBuffer` ceiling. BuildKit's progress output can run to
1136
- * tens of MB on multi-stage builds with `# syntax=docker/dockerfile:1`
1137
- * frontend downloads + heredoc / `RUN --mount=...` features; the 50 MB
1138
- * `execFile` ceiling cdk-local used to set silently killed those builds
1139
- * with `ERR_CHILD_PROCESS_STDIO_MAXBUFFER`.
1140
- *
1141
- * Output handling: stdout/stderr are collected in memory unconditionally so
1142
- * `runDockerStreaming` can return them to the caller for error wrapping.
1143
- * When the logger is at debug level (i.e. the user passed `--verbose`),
1144
- * the chunks are ALSO mirrored to `process.stdout` / `process.stderr` so
1145
- * the user sees live build progress.
1146
- */
1147
- /**
1148
- * Return the docker-compatible CLI binary to invoke. Matches CDK CLI:
1149
- * `CDK_DOCKER` env var overrides the default `docker` so users on
1150
- * podman / finch / nerdctl can swap without changing cdk-local code.
1151
- */
1152
- function getDockerCmd() {
1153
- const override = process.env["CDK_DOCKER"];
1154
- return override && override.length > 0 ? override : "docker";
1155
- }
1156
- /**
1157
- * Is the container client finch running its Lima VM (macOS / Windows)? There
1158
- * the value-less `-e KEY` form (`appendEnvFlags` in `local/docker-runner.ts`)
1159
- * does NOT keep a value off the process command line (issue #749): finch's
1160
- * `handleEnv` resolves each bare `KEY` against its own environment and
1161
- * re-emits `-e KEY=<value>` on the argv of the `limactl shell finch sudo -E
1162
- * nerdctl ...` child it starts, and `--env-file` is read on the host and
1163
- * re-emitted the same way (runfinch/finch `cmd/finch/nerdctl_remote.go`,
1164
- * `handleEnv` / `handleEnvFile`). Its `passedEnvs` list also puts
1165
- * `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` from its
1166
- * environment on that argv for every command. finch on Linux passes the argv
1167
- * to nerdctl unchanged and is NOT matched.
1168
- *
1169
- * Detection is by the BASENAME of the resolved binary (`finch`, `finch.exe`,
1170
- * any case): a wrapper script or a symlink under another name is not
1171
- * recognised, and neither is Lima's own `nerdctl.lima`.
1172
- */
1173
- function isFinchVmClient(cmd = getDockerCmd(), platform = process.platform) {
1174
- if (platform !== "darwin" && platform !== "win32") return false;
1175
- const base = (cmd.split(/[\\/]/).pop() ?? "").toLowerCase();
1176
- return base === "finch" || base === "finch.exe";
1177
- }
1178
- /**
1179
- * The env var that accepts, while it is set, that a container client puts
1180
- * secret VALUES on a process command line ({@link finchSecretArgvRefusal}):
1181
- * `<envPrefix>_ALLOW_SECRETS_ON_ARGV`, so `CDKL_ALLOW_SECRETS_ON_ARGV` for
1182
- * `cdkl` and the embedding host's own prefix otherwise. Only `1` or `true`
1183
- * (any case) opts in.
1184
- */
1185
- function allowSecretsOnArgvEnvName() {
1186
- return `${getEmbedConfig().envPrefix}_ALLOW_SECRETS_ON_ARGV`;
1187
- }
1188
- function secretsOnArgvAllowed() {
1189
- const v = process.env[allowSecretsOnArgvEnvName()];
1190
- return v !== void 0 && ["1", "true"].includes(v.trim().toLowerCase());
1191
- }
1192
- const MESSAGE_UNSAFE_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g;
1193
- /**
1194
- * Render template- or state-sourced text (an env var name, a container name,
1195
- * an image URI) for a message: control and line-separator characters are
1196
- * escaped as `\uXXXX` so a hostile value cannot drive the terminal or forge a
1197
- * log line. Plain text renders unchanged.
1198
- */
1199
- function escapeForMessage(text) {
1200
- return text.replace(MESSAGE_UNSAFE_CHARS, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
1201
- }
1202
- /**
1203
- * The refusal text when template-sourced secrets (ECS `Secrets`, decrypted
1204
- * `SecureString` values) would be forwarded under {@link isFinchVmClient}, or
1205
- * `undefined` when they may be forwarded: another client, no such secret, or
1206
- * {@link allowSecretsOnArgvEnvName} set. The caller throws it in its own error
1207
- * class BEFORE any `docker run`. The AWS credential set is not refused here
1208
- * but warned about by {@link warnFinchArgvExposure}: forwarding it is what puts
1209
- * credentials that are not in this process's own environment (`--assume-role`,
1210
- * `--profile`, the metadata sidecar's) on that argv, and the warning makes that
1211
- * visible while keeping finch usable for containers that need AWS access. Names
1212
- * only, never a value. `subject` is context such as the container name; it is
1213
- * escaped like the names.
1214
- */
1215
- function finchSecretArgvRefusal(secretNames, subject) {
1216
- if (secretNames.length === 0 || !isFinchVmClient() || secretsOnArgvAllowed()) return void 0;
1217
- const names = [...new Set(secretNames)];
1218
- return `${escapeForMessage(subject)}: refusing to forward secret(s) ${names.map(escapeForMessage).join(", ")} under CDK_DOCKER=finch on macOS / Windows. finch turns each value-less '-e KEY' into '-e KEY=<value>' on the command line of the limactl process it starts, where other local processes can read the plaintext. Use a container client that keeps the value off the command line (for example the default 'docker', by unsetting CDK_DOCKER), or set ${allowSecretsOnArgvEnvName()}=1 to accept that exposure while it stays set.`;
1219
- }
1220
- /** Key sets already warned about by {@link warnFinchArgvExposure} in this process. */
1221
- const finchArgvWarned = /* @__PURE__ */ new Set();
1222
- /**
1223
- * Warn, once per process per distinct key set, that under
1224
- * {@link isFinchVmClient} the values of `keys` (the sensitive env about to be
1225
- * forwarded as value-less `-e KEY`) reach the `limactl` command line. Names
1226
- * only, never a value.
1227
- */
1228
- function warnFinchArgvExposure(keys) {
1229
- if (keys.length === 0 || !isFinchVmClient()) return;
1230
- const names = [...new Set(keys)].sort();
1231
- const latch = JSON.stringify(names);
1232
- if (finchArgvWarned.has(latch)) return;
1233
- finchArgvWarned.add(latch);
1234
- getLogger().warn(`CDK_DOCKER=finch on macOS / Windows puts the values of ${names.map(escapeForMessage).join(", ")} on the command line of the limactl process it starts (finch turns a value-less '-e KEY' into '-e KEY=<value>'), where other local processes can read them. The default 'docker' client keeps them off the command line.`);
1235
- }
1236
- /**
1237
- * Spawn a docker-compatible CLI binary (resolved via `getDockerCmd`) with
1238
- * streaming I/O. Collects stdout/stderr in memory and resolves with both
1239
- * on exit code 0; rejects with a `SpawnError` carrying both streams on any
1240
- * non-zero exit so the caller can wrap with its own error class without
1241
- * losing the upstream output.
1242
- *
1243
- * No `maxBuffer` ceiling: BuildKit progress output frequently exceeds the
1244
- * `child_process.execFile` default of 1 MB (cdk-local previously bumped to 50 MB
1245
- * but BuildKit + frontend pulls can still exceed that on first-time builds).
1246
- */
1247
- async function runDockerStreaming(args, options = {}) {
1248
- return spawnStreaming(getDockerCmd(), args, options);
1249
- }
1250
- /**
1251
- * Generic streaming spawn — used by `runDockerStreaming` AND by the
1252
- * `executable` source mode in `docker-build.ts` (which runs an arbitrary
1253
- * user-supplied build command, not docker).
1254
- */
1255
- async function spawnStreaming(cmd, args, options = {}) {
1256
- const streamLive = options.streamLive ?? getLogger().getLevel() === "debug";
1257
- const env = options.env ? mergeEnv(options.env) : void 0;
1258
- const spin = startProgressSpinner(options.progressLabel, streamLive);
1259
- return new Promise((resolve, reject) => {
1260
- let child;
1261
- try {
1262
- child = spawn(cmd, args, {
1263
- cwd: options.cwd,
1264
- env,
1265
- stdio: [
1266
- options.input ? "pipe" : "ignore",
1267
- "pipe",
1268
- "pipe"
1269
- ]
1270
- });
1271
- } catch (err) {
1272
- stopProgressSpinner(spin, options.progressLabel);
1273
- reject(err);
1274
- return;
1275
- }
1276
- const stdoutChunks = [];
1277
- const stderrChunks = [];
1278
- child.stdout.on("data", (chunk) => {
1279
- stdoutChunks.push(chunk);
1280
- if (streamLive) process.stdout.write(chunk);
1281
- });
1282
- child.stderr.on("data", (chunk) => {
1283
- stderrChunks.push(chunk);
1284
- if (streamLive) process.stderr.write(chunk);
1285
- });
1286
- child.once("error", (err) => {
1287
- stopProgressSpinner(spin, options.progressLabel);
1288
- if (err.code === "ENOENT") {
1289
- const usingOverride = process.env["CDK_DOCKER"] === cmd && cmd !== "docker";
1290
- const shownCmd = displayUntrustedValue(cmd);
1291
- reject(/* @__PURE__ */ new Error(usingOverride ? `Failed to find and execute ${shownCmd} (resolved via CDK_DOCKER). Install ${shownCmd} or unset CDK_DOCKER to fall back to 'docker'.` : `Failed to find and execute ${shownCmd}. Install Docker (or set the 'CDK_DOCKER' environment variable to a compatible binary such as podman / finch).`));
1292
- } else {
1293
- const wrapped = /* @__PURE__ */ new Error(`Failed to execute ${displayUntrustedValue(cmd)} (${err.code ?? "spawn error"})`);
1294
- if (err.code !== void 0) wrapped.code = err.code;
1295
- reject(wrapped);
1296
- }
1297
- });
1298
- child.once("close", (code) => {
1299
- const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
1300
- const stderr = Buffer.concat(stderrChunks).toString("utf-8");
1301
- if (code === 0) {
1302
- stopProgressSpinner(spin, options.progressLabel);
1303
- resolve({
1304
- stdout,
1305
- stderr
1306
- });
1307
- } else {
1308
- stopProgressSpinner(spin, options.progressLabel);
1309
- const message = stderr.trim() || stdout.trim() || `${displayUntrustedValue(cmd)} exited with code ${code}`;
1310
- const err = new Error(message);
1311
- err.stderr = stderr;
1312
- err.stdout = stdout;
1313
- err.exitCode = code;
1314
- reject(err);
1315
- }
1316
- });
1317
- if (options.input !== void 0) {
1318
- child.stdin.on("error", () => {});
1319
- child.stdin.write(options.input);
1320
- child.stdin.end();
1321
- }
1322
- });
1323
- }
1324
- /**
1325
- * Start an interactive clack spinner for the spawn, but only when the
1326
- * current shell would actually render it (TTY) and the caller isn't
1327
- * already streaming live output. Returns `undefined` when either
1328
- * precondition fails — `stopProgressSpinner` then is a no-op.
1329
- *
1330
- * Test seam: the integration with `@clack/prompts` is mocked in
1331
- * `tests/unit/utils/docker-cmd-progress-spinner.test.ts`.
1332
- */
1333
- function startProgressSpinner(label, streamLive) {
1334
- if (label === void 0 || streamLive || process.stdout.isTTY !== true) return void 0;
1335
- const spin = spinner();
1336
- spin.start(label);
1337
- return spin;
1338
- }
1339
- function stopProgressSpinner(spin, label) {
1340
- if (spin === void 0) return;
1341
- spin.stop(label ?? "");
1342
- }
1343
- /**
1344
- * Spawn a docker-compatible CLI binary (resolved via `getDockerCmd`) attached
1345
- * to the parent process's stdio so the user sees live output (`docker pull`
1346
- * layer progress, `docker login` interactive prompts that should never fire
1347
- * with `--password-stdin` but still safe to inherit, etc.). Resolves on exit
1348
- * code 0; rejects with a plain `Error` carrying the exit code on any non-zero
1349
- * exit, so the caller can wrap with its own error class.
1350
- *
1351
- * Differs from {@link runDockerStreaming} in two ways:
1352
- * 1. `stdio: 'inherit'` — output is NOT captured, so terminal control codes
1353
- * (color, progress bar overwrites) flow through unchanged. This is the
1354
- * load-bearing reason for the split: `docker pull`'s progress bars only
1355
- * animate properly when stdout is a real TTY connected to the parent.
1356
- * 2. No `input` / `streamLive` options — inherit-mode has nothing to
1357
- * capture and nothing to mirror.
1358
- *
1359
- * Used by the `--verbose`-mode `docker pull` plumbing in `docker-runner.ts`
1360
- * and `ecr-puller.ts` (visible layer progress). Non-verbose pulls go through
1361
- * {@link runDockerStreaming} so stderr can be folded into the error message.
1362
- */
1363
- async function runDockerForeground(args, options = {}) {
1364
- return spawnForeground(getDockerCmd(), args, options);
1365
- }
1366
- /**
1367
- * Foreground (stdio-inherit) spawn — the inherit-mode counterpart to
1368
- * {@link spawnStreaming}. Used by {@link runDockerForeground} for docker-CLI
1369
- * subprocesses.
1370
- *
1371
- * The ENOENT branch crafts a docker-specific install hint ("Install Docker
1372
- * (or set CDK_DOCKER ...)"), so non-docker callers reusing this helper
1373
- * would see a misleading error on missing-binary failures. Keep the binary
1374
- * docker-shaped, or update the ENOENT message before adding a non-docker
1375
- * call site.
1376
- */
1377
- async function spawnForeground(cmd, args, options = {}) {
1378
- const env = options.env ? mergeEnv(options.env) : void 0;
1379
- return new Promise((resolve, reject) => {
1380
- const child = spawn(cmd, args, {
1381
- cwd: options.cwd,
1382
- env,
1383
- stdio: "inherit"
1384
- });
1385
- child.once("error", (err) => {
1386
- if (err.code === "ENOENT") {
1387
- const usingOverride = process.env["CDK_DOCKER"] === cmd && cmd !== "docker";
1388
- const shownCmd = displayUntrustedValue(cmd);
1389
- reject(/* @__PURE__ */ new Error(usingOverride ? `Failed to find and execute ${shownCmd} (resolved via CDK_DOCKER). Install ${shownCmd} or unset CDK_DOCKER to fall back to 'docker'.` : `Failed to find and execute ${shownCmd}. Install Docker (or set the 'CDK_DOCKER' environment variable to a compatible binary such as podman / finch).`));
1390
- } else reject(/* @__PURE__ */ new Error(`${cmd} failed: ${err.message}`));
1391
- });
1392
- child.once("close", (code) => {
1393
- if (code === 0) resolve();
1394
- else reject(/* @__PURE__ */ new Error(`${cmd} exited with code ${code}`));
1395
- });
1396
- });
1397
- }
1398
- /**
1399
- * Format the stderr from a failed `docker login` so the surfaced cdk-local
1400
- * error gives the user an actionable workaround when the underlying
1401
- * failure is a credential-helper persistence bug (which has nothing to
1402
- * do with cdk-local, AWS, or IAM perms — the docker CLI itself fails to
1403
- * save the auth token to the platform's credential store). The most
1404
- * common shape is `osxkeychain` on macOS rejecting an overwrite for
1405
- * an existing entry, but `wincred` (Windows), `pass` (Linux), and
1406
- * `secretservice` (Linux) hit the same class of `Error saving
1407
- * credentials` failure, so the rewritten message stays platform-
1408
- * agnostic — `docker logout <endpoint>` is the correct recovery on
1409
- * every backend.
1410
- *
1411
- * Detected docker / docker-credential-* output patterns:
1412
- * - `error storing credentials - err: exit status 1, out: \`The
1413
- * specified item already exists in the keychain.\`` (osxkeychain)
1414
- * - `Error saving credentials: ...` (any backend)
1415
- *
1416
- * Non-matching failures (genuine IAM / network / endpoint problems)
1417
- * pass through with just the stderr trimmed — the original message
1418
- * stays load-bearing for diagnosis.
1419
- */
1420
- function formatDockerLoginError(stderr, endpoint) {
1421
- const trimmed = stderr.trim();
1422
- if (trimmed.includes("already exists in the keychain") || trimmed.includes("Error saving credentials")) return `docker's credential helper (osxkeychain on macOS / wincred on Windows / pass / secretservice on Linux) failed to persist the ECR auth token. The "already exists in the keychain" / "Error saving credentials" output is a known docker-credential-helpers issue — unrelated to ${getEmbedConfig().productName}, AWS credentials, or IAM perms. Quick fix: run \`docker logout ${endpoint}\` to clear the stale entry, then retry the ${getEmbedConfig().productName} command. Permanent fix: edit ~/.docker/config.json and remove (or empty) the platform-specific "credsStore" entry (e.g. "osxkeychain" → "" or "desktop" on macOS Docker Desktop). Original docker stderr: ${trimmed}`;
1423
- return trimmed;
1424
- }
1425
- function mergeEnv(overrides) {
1426
- const merged = { ...process.env };
1427
- for (const [k, v] of Object.entries(overrides)) if (v === void 0) delete merged[k];
1428
- else merged[k] = v;
1429
- return merged;
1430
- }
1431
- /**
1432
- * Env vars the container CLI itself reads to decide how / where to run — the
1433
- * binary {@link getDockerCmd} resolves, i.e. `docker` or whatever `CDK_DOCKER`
1434
- * names (podman / nerdctl / finch). A resolved ECS secret (or SecureString)
1435
- * whose NAME collides with one of these must NOT override it in the client's
1436
- * own process environment: a secret named `DOCKER_HOST` (or podman's
1437
- * `CONTAINER_HOST`) would redirect the client to a different daemon, and `PATH`
1438
- * would break locating the binary. See issues
1439
- * go-to-k/cdkd#2183 and go-to-k/cdkd#2188.
1440
- *
1441
- * Ported from cdkd's `src/utils/docker-cmd.ts` (go-to-k/cdk-local#772) with its
1442
- * lists, prefix families and rationale intact; issue references in these
1443
- * comments point at cdkd, where each entry was decided. Keep the two copies
1444
- * in sync.
1445
- *
1446
- * RULE for additions: anything a supported container CLIENT (or a credential /
1447
- * connection helper it execs) reads to decide WHAT CODE IT LOADS, WHAT IT
1448
- * TRUSTS, or WHERE / HOW IT CONNECTS. The docker CLI's documented set is kept
1449
- * whole, behaviour toggles included; beyond it, a var that only tunes
1450
- * behaviour (a storage driver, a snapshotter, a temp dir, an experimental
1451
- * toggle) is OUT, because dropping a colliding secret costs the user that
1452
- * secret. The operator's OWN value is never touched — the spawn
1453
- * env starts from `process.env` — so adding a key costs only a secret of that
1454
- * name.
1455
- */
1456
- const DOCKER_CLIENT_ENV_KEYS = /* @__PURE__ */ new Set([
1457
- "PATH",
1458
- "PATHEXT",
1459
- "HOME",
1460
- "USERPROFILE",
1461
- "HOMEDRIVE",
1462
- "HOMEPATH",
1463
- "DOCKER_HOST",
1464
- "DOCKER_CONTEXT",
1465
- "DOCKER_CONFIG",
1466
- "DOCKER_CERT_PATH",
1467
- "DOCKER_TLS",
1468
- "DOCKER_TLS_VERIFY",
1469
- "DOCKER_API_VERSION",
1470
- "DOCKER_AUTH_CONFIG",
1471
- "DOCKER_DEFAULT_PLATFORM",
1472
- "DOCKER_CUSTOM_HEADERS",
1473
- "DOCKER_CONTENT_TRUST",
1474
- "DOCKER_CONTENT_TRUST_SERVER",
1475
- "DOCKER_HIDE_LEGACY_COMMANDS",
1476
- "BUILDKIT_PROGRESS",
1477
- "GLIBC_TUNABLES",
1478
- "GCONV_PATH",
1479
- "BASH_ENV",
1480
- "SHELLOPTS",
1481
- "PS4",
1482
- "PYTHONPATH",
1483
- "PYTHONHOME",
1484
- "PYTHONUSERBASE",
1485
- "PYTHONPYCACHEPREFIX",
1486
- "PYTHONPLATLIBDIR",
1487
- "PYTHONWARNINGS",
1488
- "BROWSER",
1489
- "REQUESTS_CA_BUNDLE",
1490
- "CURL_CA_BUNDLE",
1491
- "HTTPLIB2_CA_CERTS",
1492
- "SSLKEYLOGFILE",
1493
- "GCE_METADATA_HOST",
1494
- "GCE_METADATA_ROOT",
1495
- "GCE_METADATA_IP",
1496
- "NODE_OPTIONS",
1497
- "NODE_PATH",
1498
- "NODE_COMPILE_CACHE",
1499
- "NODE_EXTRA_CA_CERTS",
1500
- "NODE_TLS_REJECT_UNAUTHORIZED",
1501
- "RUBYOPT",
1502
- "RUBYLIB",
1503
- "GEM_PATH",
1504
- "GEM_HOME",
1505
- "RUBYGEMS_GEMDEPS",
1506
- "PERL5OPT",
1507
- "PERL5LIB",
1508
- "PERLLIB",
1509
- "PERL5DB",
1510
- "OPENSSL_CONF",
1511
- "OPENSSL_MODULES",
1512
- "OPENSSL_ENGINES",
1513
- "SSH_AUTH_SOCK",
1514
- "SSH_ASKPASS",
1515
- "SSH_ASKPASS_REQUIRE",
1516
- "SSH_SK_HELPER",
1517
- "SSH_SK_PROVIDER",
1518
- "SSH_PKCS11_HELPER",
1519
- "SSH_AGENT_PID",
1520
- "AWS_ENDPOINT_URL",
1521
- "AWS_CA_BUNDLE",
1522
- "AWS_PROFILE",
1523
- "AWS_CONFIG_FILE",
1524
- "AWS_SHARED_CREDENTIALS_FILE",
1525
- "AWS_WEB_IDENTITY_TOKEN_FILE",
1526
- "AWS_CONTAINER_CREDENTIALS_FULL_URI",
1527
- "AWS_ROLE_ARN",
1528
- "AWS_EC2_METADATA_SERVICE_ENDPOINT",
1529
- "AWS_ECR_CACHE_DIR",
1530
- "CONTAINER_HOST",
1531
- "CONTAINER_CONNECTION",
1532
- "CONTAINER_SSHKEY",
1533
- "PODMAN_CONNECTIONS_CONF",
1534
- "CONTAINER_PROXY",
1535
- "CONTAINERS_SSH_CONF",
1536
- "CONTAINERS_CONF",
1537
- "CONTAINERS_CONF_OVERRIDE",
1538
- "CONTAINERS_REGISTRIES_CONF",
1539
- "CONTAINERS_REGISTRIES_CONF_OVERRIDE",
1540
- "REGISTRIES_CONFIG_PATH",
1541
- "CONTAINERS_STORAGE_CONF",
1542
- "CONTAINERS_STORAGE_CONF_OVERRIDE",
1543
- "CONTAINERS_POLICY_JSON",
1544
- "STORAGE_OPTS",
1545
- "CONTAINERS_HELPER_BINARY_DIR",
1546
- "REGISTRY_AUTH_FILE",
1547
- "DBUS_SESSION_BUS_ADDRESS",
1548
- "NOTIFY_SOCKET",
1549
- "XDG_CONFIG_HOME",
1550
- "XDG_RUNTIME_DIR",
1551
- "APPDATA",
1552
- "PROGRAMDATA",
1553
- "PROGRAMFILES",
1554
- "LOCALAPPDATA",
1555
- "SSH",
1556
- "CONTAINERD_ADDRESS",
1557
- "CONTAINERD_NAMESPACE",
1558
- "NERDCTL_TOML",
1559
- "CNI_PATH",
1560
- "NETCONFPATH",
1561
- "ROOTLESSKIT_STATE_DIR",
1562
- "NERDCTL_LOG_FILE",
1563
- "SSL_CERT_FILE",
1564
- "SSL_CERT_DIR",
1565
- "GODEBUG",
1566
- "HTTP_PROXY",
1567
- "HTTPS_PROXY",
1568
- "NO_PROXY",
1569
- "FTP_PROXY",
1570
- "ALL_PROXY"
1571
- ]);
1572
- const DOCKER_CLIENT_ENV_KEYS_UPPER = new Set([...DOCKER_CLIENT_ENV_KEYS].map((k) => k.toUpperCase()));
1573
- /**
1574
- * Env-var prefixes whose WHOLE family the docker client (or a helper it execs)
1575
- * reads, so a NAMED list is always one release behind and a colliding secret in
1576
- * ANY member is a hazard. `LD_*` / `DYLD_*` are the dynamic loader (code
1577
- * injection, glibc + macOS); `AWS_ENDPOINT_URL_*` is the per-service endpoint
1578
- * family aws-sdk-go-v2 (and so `docker-credential-ecr-login`) honours — a
1579
- * secret named `AWS_ENDPOINT_URL_ECR` walks around the exact
1580
- * `AWS_ENDPOINT_URL` entry and redirects a request signed with the operator's
1581
- * real credentials (go-to-k/cdkd#2186 round 4). `CLOUDSDK_` is gcloud's (go-to-k/cdkd#3599): every
1582
- * gcloud property is settable as `CLOUDSDK_<SECTION>_<NAME>`, which covers
1583
- * its token host, API endpoint overrides, proxy, CA bundle and account, and
1584
- * the `docker-credential-gcloud` wrapper execs `$CLOUDSDK_PYTHON
1585
- * $CLOUDSDK_PYTHON_ARGS`. `BASH_FUNC_` is bash's exported-function family.
1586
- * No plausible secret name collides with these, apart from the one listed in
1587
- * {@link DOCKER_CLIENT_ENV_PREFIX_EXEMPTIONS}. Matched by prefix rather than
1588
- * enumerated (issue go-to-k/cdkd#2183 review). `SSH_` was a prefix here and was demoted to
1589
- * an EXACT enumeration in {@link DOCKER_CLIENT_ENV_KEYS} (go-to-k/cdkd#2186 review round
1590
- * 3): the family is not
1591
- * uniformly dangerous and is not growing, while the prefix broke realistic,
1592
- * currently-working secrets (`SSH_PRIVATE_KEY`, GitLab CI's canonical
1593
- * deploy-key spelling). Exported so the test fence can assert the EXACT
1594
- * contents — a hardcoded copy in the test made the anti-shadowing fence
1595
- * one-directional (go-to-k/cdkd#2186 round 4 finding 2).
1596
- */
1597
- const DOCKER_CLIENT_ENV_PREFIXES = [
1598
- "LD_",
1599
- "DYLD_",
1600
- "AWS_ENDPOINT_URL_",
1601
- "CLOUDSDK_",
1602
- "BASH_FUNC_"
1603
- ];
1604
- /**
1605
- * Exact names inside a {@link DOCKER_CLIENT_ENV_PREFIXES} family that are still
1606
- * delivered. An entry must be a realistic secret name AND harmless to the
1607
- * helper that reads it. `CLOUDSDK_AUTH_ACCESS_TOKEN` is gcloud's own variable
1608
- * for a caller-supplied access token (go-to-k/cdkd#3599). Given to `docker-credential-gcloud`,
1609
- * it only changes which token gcloud hands docker, with no network call of its
1610
- * own. gcloud's `auth docker-helper` answers only for a registry in its own
1611
- * supported list unless `artifacts/allow_unrecognized_registry` is set, and
1612
- * that property, the token host, the universe domain and every other variable
1613
- * that could redirect or weaken the exchange stay refused by the `CLOUDSDK_`
1614
- * prefix. Stored upper-case and matched case-insensitively. Never exempt an
1615
- * exact {@link DOCKER_CLIENT_ENV_KEYS} member: the exact list wins.
1616
- */
1617
- const DOCKER_CLIENT_ENV_PREFIX_EXEMPTIONS = /* @__PURE__ */ new Set(["CLOUDSDK_AUTH_ACCESS_TOKEN"]);
1618
- /**
1619
- * Is `key` the name of a var the docker client reads? Case-INSENSITIVE, because
1620
- * Windows environment lookups are, so a lowercase `docker_host` must be caught
1621
- * too (issue go-to-k/cdkd#2183). Matches the exact denylist OR a prefixed family — the
1622
- * prefix families are fail-closed on the whole prefix, so an unlisted `LD_*` /
1623
- * `DYLD_*` / `AWS_ENDPOINT_URL_*` / `CLOUDSDK_*` / `BASH_FUNC_*` secret is
1624
- * dropped (with a rename warning) rather than reaching the client, except for
1625
- * a name in {@link DOCKER_CLIENT_ENV_PREFIX_EXEMPTIONS}.
1626
- */
1627
- function isDockerClientEnvKey(key) {
1628
- const upper = key.toUpperCase();
1629
- if (DOCKER_CLIENT_ENV_KEYS_UPPER.has(upper)) return true;
1630
- if (DOCKER_CLIENT_ENV_PREFIX_EXEMPTIONS.has(upper)) return false;
1631
- return DOCKER_CLIENT_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
1632
- }
1633
- /**
1634
- * A well-formed `docker run -e` variable NAME: non-empty, and containing
1635
- * neither `=` (the OS parses the environ entry's name as everything before the
1636
- * first one) nor NUL (Node refuses to spawn). A newline IS accepted, because it
1637
- * is inside the class — not because of the anchor: JS `$` without the `m` flag
1638
- * matches only at end of input (`/^abc$/.test('abc\n') === false`). That
1639
- * matches the clause list this replaced, i.e. deliberately not stricter.
1640
- */
1641
- const WELL_FORMED_ENV_KEY = /^[^=\u0000]+$/;
1642
- /**
1643
- * Is `key` a shape that cannot be a well-formed `docker run -e` variable NAME?
1644
- * Defined POSITIVELY as {@link WELL_FORMED_ENV_KEY}'s complement (go-to-k/cdkd#2186 rounds
1645
- * 5-6). Enumerating the bad spellings one at a time closed `=` in round 4 and
1646
- * left the empty key (`-e ''` — docker rejects it with an opaque error naming
1647
- * no secret) and a NUL-bearing key still open; the complement closes any
1648
- * further bad shape without another clause. A sensitive key matching this
1649
- * takes the same fail-closed
1650
- * collision path as a docker-client-var name: no `-e` flag, no spawn-env entry,
1651
- * reported in `collisions`. (This is the NAME only.)
1652
- */
1653
- function isMalformedEnvKey(key) {
1654
- return !WELL_FORMED_ENV_KEY.test(key);
1655
- }
1656
-
1657
- //#endregion
1658
- export { stringifyThrown as C, resetEmbedConfig as D, getEmbedConfig as E, setEmbedConfig as O, sanitizeServiceExceptionMessage as S, resolveConfiguredLogLevel as T, clampErrorName as _, isDockerClientEnvKey as a, flattenToOneLine as b, runDockerStreaming as c, absoluteAssemblyPathEscape as d, assemblyPathEscape as f, resolveAssemblyPath as g, renderAssemblyPathEscape as h, getDockerCmd as i, spawnStreaming as l, namesTheSameDirectory as m, finchSecretArgvRefusal as n, isMalformedEnvKey as o, displayUntrustedValue as p, formatDockerLoginError as r, runDockerForeground as s, docker_cmd_exports as t, warnFinchArgvExposure as u, describeAwsFailureForWarn as v, getLogger as w, isAwsServiceException as x, describeCredentialLoadFailure as y };
1659
- //# sourceMappingURL=docker-cmd-leyvDn1u.js.map