Skip to content

Commit 3580227

Browse files
committed
fix(cli): reject digit-less durations and disabled-API schema writes in config push
- legacyParseDuration requires a digit in every component, so 's' or 'ms' no longer parses as zero and reaches the wire. - A schemas/extra_search_path/max_rows change against a project whose Data API is disabled is reported unencodable instead of riding along with the disable sentinel. - Every encoder accounts for every routed change (unmatched paths are reported, never dropped), with a drift guard over all comparable routed paths; companion values of an unexpected type make the group unencodable instead of defaulting. - Secrets dropped with their group render as not pushed, land in a sixth secrets.unencodable bucket, and the summary no longer claims the project matches while something was withheld.
1 parent 27868ef commit 3580227

9 files changed

Lines changed: 511 additions & 65 deletions

File tree

apps/cli/src/legacy/commands/config/push/SIDE_EFFECTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ asserting `false` would be as misleading as asserting `true`:
273273
"unchanged": [],
274274
"not_set": [],
275275
"gated": [],
276+
"unencodable": [],
276277
"skipped": [],
277278
},
278279
"declined_addons": [],
@@ -329,7 +330,7 @@ mirroring — but not equal to — a `config.toml` path, plus the fixed string
329330
contract; every other field is additive. When the branch gate declines (machine format
330331
without `--yes`), the command fails (exit `1`) with the standard error envelope in place
331332
of the success payload — see above. `secrets` partitions every declared secret
332-
(`changeSet.masked`) across its five buckets, reporting what was OBSERVED to happen —
333+
(`changeSet.masked`) across its six buckets, reporting what was OBSERVED to happen —
333334
`sent` only when the auth write actually ran AND the container carrying that secret
334335
placed it in the request body. A `send`-decided secret whose write did not run
335336
(declined prompt, or auth not written for any other reason) lands in `skipped`

apps/cli/src/legacy/commands/config/push/push.duration.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,23 @@ export function legacyParseDuration(s: string): number {
4444
n = n * 10 + parseInt(s.charAt(i), 10);
4545
i++;
4646
}
47+
const intDigits = i;
48+
let fracDigits = 0;
4749
if (i < s.length && s.charAt(i) === ".") {
4850
i++;
51+
const fracStart = i;
4952
while (i < s.length && s.charAt(i) >= "0" && s.charAt(i) <= "9") {
5053
frac = frac * 10 + parseInt(s.charAt(i), 10);
5154
post *= 10;
5255
i++;
5356
}
57+
fracDigits = i - fracStart;
58+
}
59+
// A component with no digit at all — bare unit ("s", "ms") or a lone "." —
60+
// must never silently parse as a zero-valued component; that would let a
61+
// typo'd duration overwrite a remote value with 0 instead of failing.
62+
if (intDigits === 0 && fracDigits === 0) {
63+
throw new Error(`time: invalid duration "${orig}"`);
5464
}
5565
s = s.slice(i);
5666
if (s.length === 0) throw new Error(`time: missing unit in duration "${orig}"`);

apps/cli/src/legacy/commands/config/push/push.duration.unit.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,20 @@ describe("legacyParseDuration", () => {
5151
it("throws on an unrecognized unit", () => {
5252
expect(() => legacyParseDuration("5d")).toThrow(/unknown unit/);
5353
});
54+
55+
it("throws on a digit-less (unit-only) component, rather than parsing it as zero", () => {
56+
expect(() => legacyParseDuration("s")).toThrow(/invalid duration/);
57+
expect(() => legacyParseDuration("ms")).toThrow(/invalid duration/);
58+
expect(() => legacyParseDuration("h")).toThrow(/invalid duration/);
59+
});
60+
61+
it("accepts a decimal-only component (a digit after the point still counts)", () => {
62+
expect(legacyParseDuration(".5s")).toBe(500_000_000);
63+
});
64+
65+
it("throws when one component of an otherwise-valid duration is digit-less", () => {
66+
expect(() => legacyParseDuration("1hm")).toThrow(/invalid duration/);
67+
expect(() => legacyParseDuration("1h0m0s0ms")).not.toThrow();
68+
expect(() => legacyParseDuration("5sms")).toThrow(/invalid duration/);
69+
});
5470
});

apps/cli/src/legacy/commands/config/push/push.encoders.ts

Lines changed: 157 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
legacyContainerEnabled,
2727
legacyIsPrefixOf,
2828
legacyIsRecord,
29+
legacyPathIn,
2930
legacySamePath,
3031
legacyValueAtPath,
3132
} from "./push.paths.ts";
@@ -147,6 +148,11 @@ const REASON_VALUE_NOT_REPRESENTABLE = "the declared value could not be represen
147148
const REASON_DB_SETTINGS_KEY_SHAPE =
148149
"only a top-level db.settings.<key> value can be encoded into a Postgres config write";
149150
const REASON_INVALID_DURATION = "the declared value is not a valid duration";
151+
const REASON_API_DISABLED =
152+
"the Data API is disabled on the project; declare api.enabled = true to apply this";
153+
const REASON_COMPANION_TYPE_MISMATCH =
154+
"a required companion value has an unexpected type and could not be sent";
155+
const REASON_NO_ENCODER = "config push has no encoder for this property";
150156

151157
// --- generic path/value helpers ---------------------------------------------
152158

@@ -291,6 +297,43 @@ function sortByPath<T extends { readonly path: ReadonlyArray<string> }>(
291297
return [...entries].sort((a, b) => legacyComparePaths(a.path, b.path));
292298
}
293299

300+
/**
301+
* Every routed change an encoder receives must land in `encoded`,
302+
* `unencodable`, or `extras` — never neither. A path missing from all three
303+
* (a resource path this encoder's own switch/branch logic forgot to handle)
304+
* is pushed to `unencodable` with {@link REASON_NO_ENCODER} instead of being
305+
* silently dropped.
306+
*/
307+
function finalizeEncoded<Body>(
308+
changes: ReadonlyArray<ConfigChange>,
309+
result: LegacyPushEncoded<Body>,
310+
): LegacyPushEncoded<Body> {
311+
const covered = [
312+
...result.encoded,
313+
...result.unencodable.map((entry) => entry.path),
314+
...result.extras.map((entry) => entry.path),
315+
];
316+
const missing = changes.filter((change) => !legacyPathIn(change.path, covered));
317+
if (missing.length === 0) {
318+
return result;
319+
}
320+
return {
321+
...result,
322+
unencodable: sortByPath([
323+
...result.unencodable,
324+
...missing.map((change) => ({ path: change.path, reason: REASON_NO_ENCODER })),
325+
]),
326+
};
327+
}
328+
329+
/** Wraps an encoder's implementation with the {@link finalizeEncoded} exhaustiveness check,
330+
* applied once here rather than at every one of an encoder's internal return points. */
331+
function withExhaustiveness<In extends LegacyPushEncoderInput, Body>(
332+
encode: (input: In) => LegacyPushEncoded<Body>,
333+
): (input: In) => LegacyPushEncoded<Body> {
334+
return (input) => finalizeEncoded(input.changes, encode(input));
335+
}
336+
294337
/** Adds one leaf mapping to `body` when `path` has a routed change; `transform` returning
295338
* `undefined` marks it unencodable instead, with `reason` (defaulting to the generic
296339
* wrong-type reason) rather than always the same catch-all. */
@@ -322,60 +365,85 @@ function makeLeafAdder(
322365

323366
// --- api ---------------------------------------------------------------------
324367

325-
export function legacyEncodeApiBody(
326-
input: LegacyPushEncoderInput,
327-
): LegacyPushEncoded<LegacyApiUpdateBody> {
368+
function encodeApiBody(input: LegacyPushEncoderInput): LegacyPushEncoded<LegacyApiUpdateBody> {
328369
const { changes, local, remote } = input;
329370
const encoded: Array<ReadonlyArray<string>> = [];
330371
const unencodable: Array<{ path: ReadonlyArray<string>; reason: string }> = [];
331372
const forced: Array<{ path: ReadonlyArray<string>; value: unknown }> = [];
332373

333-
let dbSchema: string | undefined;
334374
const enabledChange = findChange(changes, ["api", "enabled"]);
335375
const schemasChange = findChange(changes, ["api", "schemas"]);
376+
const extraSearchPathChange = findChange(changes, ["api", "extra_search_path"]);
377+
const maxRowsChange = findChange(changes, ["api", "max_rows"]);
378+
379+
// Whether the Data API is currently disabled and staying that way: `enabled`
380+
// itself is not a routed change, and its resolved (remote-preferred) value
381+
// is `false`. Every OTHER api.* change is then meaningless to send — there
382+
// is no live Data API for the platform to apply it to — so each routes to
383+
// `unencodable` instead of either silently riding along inside the `""`
384+
// disable sentinel (a schemas-only change would otherwise be swallowed by
385+
// it) or being sent on its own to an endpoint that ignores it while
386+
// disabled.
387+
const apiDisabled =
388+
enabledChange === undefined &&
389+
asBoolean(resolveLeaf(changes, ["api", "enabled"], remote, local).value) === false;
390+
391+
let dbSchema: string | undefined;
336392
if (enabledChange !== undefined || schemasChange !== undefined) {
337393
const triggerPaths = [enabledChange, schemasChange]
338394
.filter((change): change is ConfigChange => change !== undefined)
339395
.map((change) => change.path);
340396

341-
const enabledResolved = resolveLeaf(changes, ["api", "enabled"], remote, local);
342-
const enabled = asBoolean(enabledResolved.value);
343-
if (enabled === false) {
344-
dbSchema = "";
345-
encoded.push(...triggerPaths);
346-
pushForced(forced, ["api", "enabled"], enabledResolved);
397+
if (apiDisabled) {
398+
for (const path of triggerPaths) {
399+
unencodable.push({ path, reason: REASON_API_DISABLED });
400+
}
347401
} else {
348-
const schemasResolved = resolveLeaf(changes, ["api", "schemas"], remote, local);
349-
const schemas = asStringArray(schemasResolved.value) ?? [];
350-
if (schemas.length === 0) {
351-
for (const path of triggerPaths) {
352-
unencodable.push({ path, reason: REASON_API_ENABLE_NEEDS_SCHEMA });
353-
}
354-
} else {
355-
dbSchema = schemas.join(",");
402+
const enabledResolved = resolveLeaf(changes, ["api", "enabled"], remote, local);
403+
const enabled = asBoolean(enabledResolved.value);
404+
if (enabled === false) {
405+
dbSchema = "";
356406
encoded.push(...triggerPaths);
357407
pushForced(forced, ["api", "enabled"], enabledResolved);
358-
pushForced(forced, ["api", "schemas"], schemasResolved);
408+
} else {
409+
const schemasResolved = resolveLeaf(changes, ["api", "schemas"], remote, local);
410+
const schemas = asStringArray(schemasResolved.value) ?? [];
411+
if (schemas.length === 0) {
412+
for (const path of triggerPaths) {
413+
unencodable.push({ path, reason: REASON_API_ENABLE_NEEDS_SCHEMA });
414+
}
415+
} else {
416+
dbSchema = schemas.join(",");
417+
encoded.push(...triggerPaths);
418+
pushForced(forced, ["api", "enabled"], enabledResolved);
419+
pushForced(forced, ["api", "schemas"], schemasResolved);
420+
}
359421
}
360422
}
361423
}
362424

363425
let dbExtraSearchPath: string | undefined;
364-
const extraSearchPathChange = findChange(changes, ["api", "extra_search_path"]);
365426
if (extraSearchPathChange !== undefined) {
366-
dbExtraSearchPath = (asStringArray(extraSearchPathChange.local) ?? []).join(",");
367-
encoded.push(extraSearchPathChange.path);
427+
if (apiDisabled) {
428+
unencodable.push({ path: extraSearchPathChange.path, reason: REASON_API_DISABLED });
429+
} else {
430+
dbExtraSearchPath = (asStringArray(extraSearchPathChange.local) ?? []).join(",");
431+
encoded.push(extraSearchPathChange.path);
432+
}
368433
}
369434

370435
let maxRows: number | undefined;
371-
const maxRowsChange = findChange(changes, ["api", "max_rows"]);
372436
if (maxRowsChange !== undefined) {
373-
const value = asNumber(maxRowsChange.local);
374-
if (value !== undefined) {
375-
maxRows = value;
376-
encoded.push(maxRowsChange.path);
437+
if (apiDisabled) {
438+
unencodable.push({ path: maxRowsChange.path, reason: REASON_API_DISABLED });
377439
} else {
378-
unencodable.push({ path: maxRowsChange.path, reason: REASON_VALUE_NOT_REPRESENTABLE });
440+
const value = asNumber(maxRowsChange.local);
441+
if (value !== undefined) {
442+
maxRows = value;
443+
encoded.push(maxRowsChange.path);
444+
} else {
445+
unencodable.push({ path: maxRowsChange.path, reason: REASON_VALUE_NOT_REPRESENTABLE });
446+
}
379447
}
380448
}
381449

@@ -396,9 +464,11 @@ export function legacyEncodeApiBody(
396464
};
397465
}
398466

467+
export const legacyEncodeApiBody = withExhaustiveness(encodeApiBody);
468+
399469
// --- db.settings ---------------------------------------------------------
400470

401-
export function legacyEncodeDbSettingsBody(
471+
function encodeDbSettingsBody(
402472
input: LegacyPushEncoderInput,
403473
): LegacyPushEncoded<LegacyDbSettingsUpdateBody> {
404474
const { changes } = input;
@@ -435,9 +505,11 @@ export function legacyEncodeDbSettingsBody(
435505
};
436506
}
437507

508+
export const legacyEncodeDbSettingsBody = withExhaustiveness(encodeDbSettingsBody);
509+
438510
// --- db.network_restrictions -----------------------------------------------
439511

440-
export function legacyEncodeNetworkRestrictionsBody(
512+
function encodeNetworkRestrictionsBody(
441513
input: LegacyPushEncoderInput,
442514
): LegacyPushEncoded<LegacyNetworkRestrictionsUpdateBody> {
443515
const { changes, local, remote } = input;
@@ -487,9 +559,13 @@ export function legacyEncodeNetworkRestrictionsBody(
487559
};
488560
}
489561

562+
export const legacyEncodeNetworkRestrictionsBody = withExhaustiveness(
563+
encodeNetworkRestrictionsBody,
564+
);
565+
490566
// --- db.ssl_enforcement -----------------------------------------------------
491567

492-
export function legacyEncodeSslEnforcementBody(
568+
function encodeSslEnforcementBody(
493569
input: LegacyPushEncoderInput,
494570
): LegacyPushEncoded<LegacySslEnforcementUpdateBody> {
495571
const { changes } = input;
@@ -516,6 +592,8 @@ export function legacyEncodeSslEnforcementBody(
516592
};
517593
}
518594

595+
export const legacyEncodeSslEnforcementBody = withExhaustiveness(encodeSslEnforcementBody);
596+
519597
// --- storage -----------------------------------------------------------------
520598

521599
interface LegacyStorageIcebergCatalogBody {
@@ -531,7 +609,7 @@ interface LegacyStorageVectorBucketsBody {
531609
readonly maxIndexes: number;
532610
}
533611

534-
export function legacyEncodeStorageBody(
612+
function encodeStorageBody(
535613
input: LegacyStorageEncoderInput,
536614
): LegacyPushEncoded<LegacyStorageUpdateBody> {
537615
const { changes, local, remote, config } = input;
@@ -704,6 +782,8 @@ export function legacyEncodeStorageBody(
704782
};
705783
}
706784

785+
export const legacyEncodeStorageBody = withExhaustiveness(encodeStorageBody);
786+
707787
// --- auth ----------------------------------------------------------------
708788

709789
/** `undefined` on a non-string or an unparseable duration — never `0`, so an invalid
@@ -779,6 +859,22 @@ function encodeSmtpContainer(
779859
}
780860
return undefined;
781861
}
862+
// A companion that DID resolve but to the wrong runtime type (never
863+
// expected from a schema-typed `ProjectConfig`, but `remote` is untyped
864+
// JSON off the wire) must never be silently coerced to `""`/`"0"` below —
865+
// it makes the whole group unencodable instead.
866+
if (
867+
typeof hostR.value !== "string" ||
868+
typeof portR.value !== "number" ||
869+
typeof userR.value !== "string" ||
870+
typeof adminEmailR.value !== "string" ||
871+
typeof senderNameR.value !== "string"
872+
) {
873+
for (const path of unencodableTargets(containerChanges, secret)) {
874+
unencodable.push({ path, reason: REASON_COMPANION_TYPE_MISMATCH });
875+
}
876+
return undefined;
877+
}
782878
pushForced(forced, [...containerPath, "host"], hostR);
783879
pushForced(forced, [...containerPath, "port"], portR);
784880
pushForced(forced, [...containerPath, "user"], userR);
@@ -914,6 +1010,20 @@ function encodeExternalProviderContainer(
9141010
}
9151011
return undefined;
9161012
}
1013+
// Each companion resolved to SOME value above — but a wrong runtime type
1014+
// (client_id/url expect a string, email_optional/skip_nonce_check expect a
1015+
// boolean) must never be silently coerced to `""`/`false` below.
1016+
if (
1017+
typeof clientIdR.value !== "string" ||
1018+
(urlR !== undefined && typeof urlR.value !== "string") ||
1019+
(emailOptionalR !== undefined && typeof emailOptionalR.value !== "boolean") ||
1020+
(skipNonceCheckR !== undefined && typeof skipNonceCheckR.value !== "boolean")
1021+
) {
1022+
for (const path of unencodableTargets(containerChanges, secret)) {
1023+
unencodable.push({ path, reason: REASON_COMPANION_TYPE_MISMATCH });
1024+
}
1025+
return undefined;
1026+
}
9171027
pushForced(forced, [...containerPath, "client_id"], clientIdR);
9181028
body[`${key}_client_id`] = asString(clientIdR.value) ?? "";
9191029
if (secret?.status === "send") {
@@ -1009,12 +1119,21 @@ function encodeActiveSmsProviderBody(
10091119
break;
10101120
}
10111121

1012-
if (resolutions.some(({ resolution }) => resolution.source === "none")) {
1122+
// Every field resolved through `field`/`optionalField` above is
1123+
// string-typed; a resolution present but of the wrong runtime type must
1124+
// never have silently fallen through `field`'s `?? ""` default, so it is
1125+
// caught here alongside the "missing entirely" case rather than shipped.
1126+
const missingResolution = resolutions.some(({ resolution }) => resolution.source === "none");
1127+
const wrongTypeResolution = resolutions.some(
1128+
({ resolution }) => resolution.source !== "none" && typeof resolution.value !== "string",
1129+
);
1130+
if (missingResolution || wrongTypeResolution) {
1131+
const reason = missingResolution ? REASON_GROUP_INCOMPLETE : REASON_COMPANION_TYPE_MISMATCH;
10131132
for (const change of containerChanges) {
1014-
unencodable.push({ path: change.path, reason: REASON_GROUP_INCOMPLETE });
1133+
unencodable.push({ path: change.path, reason });
10151134
}
10161135
for (const path of sentSecretPaths) {
1017-
unencodable.push({ path, reason: REASON_GROUP_INCOMPLETE });
1136+
unencodable.push({ path, reason });
10181137
}
10191138
return undefined;
10201139
}
@@ -1265,7 +1384,7 @@ export const LEGACY_PUSH_AUTH_LEAF_MAP: ReadonlyArray<LegacyPushAuthLeafSpec> =
12651384
},
12661385
];
12671386

1268-
export function legacyEncodeAuthBody(
1387+
function encodeAuthBody(
12691388
input: LegacyAuthEncoderInput,
12701389
): LegacyPushEncoded<Readonly<Record<string, unknown>>> {
12711390
const { changes, local, remote, secrets, emailContent, remoteAuthAttributes, now } = input;
@@ -1495,3 +1614,5 @@ export function legacyEncodeAuthBody(
14951614
secretsEncoded: [...secretsEncoded].sort(legacyComparePaths),
14961615
};
14971616
}
1617+
1618+
export const legacyEncodeAuthBody = withExhaustiveness(encodeAuthBody);

0 commit comments

Comments
 (0)