From 7c4f47c403771188af0d2fda5df266b083be2b44 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Thu, 15 Oct 2026 12:20:48 -0700 Subject: [PATCH] fix(config): coerce .env values to numbers only when they are plain integers loadEnvFile() converted any isNumeric() value without a "." through val(), so 1e3 became 1 and 007 became 7. It now converts only values matching ^-?(0|[1-9][0-9]*)$; 007, 1e3 and +5 stay strings, while 0, 42 and -5 still become numbers. A value that is a single quote character no longer reaches mid() with a negative length: quotes are stripped only when the value has at least two characters. The same change is applied to all four copies of the parser (this repo's public/Application.cfc, the wheels new template, and the two examples). Specs pin the edge values and guard that every copy carries the new check. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Peter Amiri --- .../env-integer-only-coercion.fixed.md | 1 + .../templates/app/public/Application.cfc | 8 ++- examples/starter-app/public/Application.cfc | 8 ++- examples/tweet/public/Application.cfc | 10 +-- public/Application.cfc | 10 +-- .../wheels/tests/_assets/env/edge-values.env | 9 +++ .../tests/specs/global/EnvFileParsingSpec.cfc | 66 +++++++++++++++++++ 7 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 changelog.d/env-integer-only-coercion.fixed.md create mode 100644 vendor/wheels/tests/_assets/env/edge-values.env diff --git a/changelog.d/env-integer-only-coercion.fixed.md b/changelog.d/env-integer-only-coercion.fixed.md new file mode 100644 index 0000000000..f341411dfd --- /dev/null +++ b/changelog.d/env-integer-only-coercion.fixed.md @@ -0,0 +1 @@ +- `.env` values now coerce to numbers only when they are plain integers, so `007`, `1e3` and `+5` stay strings instead of being truncated to `7` and `1`, and a value that is a lone quote no longer errors. Apps generated by `wheels new` keep the old parser in their own `public/Application.cfc` until they copy the change. diff --git a/cli/lucli/templates/app/public/Application.cfc b/cli/lucli/templates/app/public/Application.cfc index e82478033e..502e7bb8d3 100644 --- a/cli/lucli/templates/app/public/Application.cfc +++ b/cli/lucli/templates/app/public/Application.cfc @@ -1029,8 +1029,9 @@ component output="false" { local.key = trim(listFirst(local.trimmedLine, "=")); local.value = trim(listRest(local.trimmedLine, "=")); - if ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || - (left(local.value, 1) == "'" && right(local.value, 1) == "'")) { + // Strip only when there is an inner value, so a lone quote survives + if (len(local.value) >= 2 && ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || + (left(local.value, 1) == "'" && right(local.value, 1) == "'"))) { local.value = mid(local.value, 2, len(local.value) - 2); } @@ -1040,7 +1041,8 @@ component output="false" { // true and made numeric settings silently take their defaults instead. if (Compare(lCase(local.value), "true") == 0 || Compare(lCase(local.value), "false") == 0) { local.value = (Compare(lCase(local.value), "true") == 0); - } else if (isNumeric(local.value) && !find(".", local.value)) { + } else if (REFind("^-?(0|[1-9][0-9]*)$", local.value) > 0) { + // Only convert plain integers; 007, 1e3 and +5 stay strings local.value = val(local.value); } diff --git a/examples/starter-app/public/Application.cfc b/examples/starter-app/public/Application.cfc index 161c9707a4..91be587a5f 100644 --- a/examples/starter-app/public/Application.cfc +++ b/examples/starter-app/public/Application.cfc @@ -1023,8 +1023,9 @@ component output="false" { local.key = trim(listFirst(local.trimmedLine, "=")); local.value = trim(listRest(local.trimmedLine, "=")); - if ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || - (left(local.value, 1) == "'" && right(local.value, 1) == "'")) { + // Strip only when there is an inner value, so a lone quote survives + if (len(local.value) >= 2 && ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || + (left(local.value, 1) == "'" && right(local.value, 1) == "'"))) { local.value = mid(local.value, 2, len(local.value) - 2); } @@ -1034,7 +1035,8 @@ component output="false" { // true and made numeric settings silently take their defaults instead. if (Compare(lCase(local.value), "true") == 0 || Compare(lCase(local.value), "false") == 0) { local.value = (Compare(lCase(local.value), "true") == 0); - } else if (isNumeric(local.value) && !find(".", local.value)) { + } else if (REFind("^-?(0|[1-9][0-9]*)$", local.value) > 0) { + // Only convert plain integers; 007, 1e3 and +5 stay strings local.value = val(local.value); } diff --git a/examples/tweet/public/Application.cfc b/examples/tweet/public/Application.cfc index da2f9047c1..0fef2258e2 100755 --- a/examples/tweet/public/Application.cfc +++ b/examples/tweet/public/Application.cfc @@ -948,9 +948,9 @@ component output="false" { local.key = trim(listFirst(local.trimmedLine, "=")); local.value = trim(listRest(local.trimmedLine, "=")); - // Remove surrounding quotes if present - if ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || - (left(local.value, 1) == "'" && right(local.value, 1) == "'")) { + // Strip only when there is an inner value, so a lone quote survives + if (len(local.value) >= 2 && ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || + (left(local.value, 1) == "'" && right(local.value, 1) == "'"))) { local.value = mid(local.value, 2, len(local.value) - 2); } @@ -961,8 +961,8 @@ component output="false" { // true and made numeric settings silently take their defaults instead. if (Compare(lCase(local.value), "true") == 0 || Compare(lCase(local.value), "false") == 0) { local.value = (Compare(lCase(local.value), "true") == 0); - } else if (isNumeric(local.value) && !find(".", local.value)) { - // Only convert integers, leave decimals as strings + } else if (REFind("^-?(0|[1-9][0-9]*)$", local.value) > 0) { + // Only convert plain integers; 007, 1e3 and +5 stay strings local.value = val(local.value); } diff --git a/public/Application.cfc b/public/Application.cfc index 281ba52326..2fcc8cbf9e 100644 --- a/public/Application.cfc +++ b/public/Application.cfc @@ -1003,9 +1003,9 @@ component output="false" { local.key = trim(listFirst(local.trimmedLine, "=")); local.value = trim(listRest(local.trimmedLine, "=")); - // Remove surrounding quotes if present - if ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || - (left(local.value, 1) == "'" && right(local.value, 1) == "'")) { + // Strip only when there is an inner value, so a lone quote survives + if (len(local.value) >= 2 && ((left(local.value, 1) == '"' && right(local.value, 1) == '"') || + (left(local.value, 1) == "'" && right(local.value, 1) == "'"))) { local.value = mid(local.value, 2, len(local.value) - 2); } @@ -1016,8 +1016,8 @@ component output="false" { // true and made numeric settings silently take their defaults instead. if (Compare(lCase(local.value), "true") == 0 || Compare(lCase(local.value), "false") == 0) { local.value = (Compare(lCase(local.value), "true") == 0); - } else if (isNumeric(local.value) && !find(".", local.value)) { - // Only convert integers, leave decimals as strings + } else if (REFind("^-?(0|[1-9][0-9]*)$", local.value) > 0) { + // Only convert plain integers; 007, 1e3 and +5 stay strings local.value = val(local.value); } diff --git a/vendor/wheels/tests/_assets/env/edge-values.env b/vendor/wheels/tests/_assets/env/edge-values.env new file mode 100644 index 0000000000..43428df0cf --- /dev/null +++ b/vendor/wheels/tests/_assets/env/edge-values.env @@ -0,0 +1,9 @@ +# Fixture for global.EnvFileParsingSpec: edge values beyond value-types.env. +# Parsed by the host app's own Application.cfc loadEnvFile(); no app .env key needed. +WHEELS_SPEC_LEADING_ZERO=007 +WHEELS_SPEC_SCI=1e3 +WHEELS_SPEC_PLUS=+5 +WHEELS_SPEC_NEGATIVE=-5 +WHEELS_SPEC_LONE_SQUOTE=' +WHEELS_SPEC_LONE_DQUOTE=" +WHEELS_SPEC_AFTER_LONE=still-parsed diff --git a/vendor/wheels/tests/specs/global/EnvFileParsingSpec.cfc b/vendor/wheels/tests/specs/global/EnvFileParsingSpec.cfc index 864216448b..2b1b8fbf35 100644 --- a/vendor/wheels/tests/specs/global/EnvFileParsingSpec.cfc +++ b/vendor/wheels/tests/specs/global/EnvFileParsingSpec.cfc @@ -143,6 +143,40 @@ component extends="wheels.WheelsTest" { }); + describe(".env edge values: lone quotes and integer-only coercion", () => { + + it("keeps plain-integer coercion and survives lone quotes", () => { + // Same host-Application pattern as the blocks above. + var hostApp = CreateObject("component", "Application"); + prepareMock(hostApp); + makePublic(hostApp, "loadEnvFile"); + var parsedEnv = {}; + var fixture = ExpandPath("/wheels/tests/_assets/env/edge-values.env"); + expect(FileExists(fixture), "fixture missing").toBeTrue(); + hostApp.loadEnvFile(fixture, parsedEnv); + + // Only plain integers coerce: a leading zero, scientific notation + // and a leading plus all stay strings. + expect(Compare(ToString(parsedEnv["WHEELS_SPEC_LEADING_ZERO"]), "007"), "007 must survive as the string 007").toBe(0); + expect(Compare(ToString(parsedEnv["WHEELS_SPEC_SCI"]), "1e3"), "1e3 must survive as the string 1e3").toBe(0); + expect(Compare(ToString(parsedEnv["WHEELS_SPEC_PLUS"]), "+5"), "+5 must survive as the string +5").toBe(0); + + // A plain negative integer still becomes a number. + expect(Compare(ToString(parsedEnv["WHEELS_SPEC_NEGATIVE"]), "-5"), "-5 must survive as the string -5").toBe(0); + expect(parsedEnv["WHEELS_SPEC_NEGATIVE"]).toBe(-5); + + // A lone quote is a 1-character string and must not be sliced away. + expect(Len(parsedEnv["WHEELS_SPEC_LONE_SQUOTE"])).toBe(1); + expect(Compare(ToString(parsedEnv["WHEELS_SPEC_LONE_SQUOTE"]), "'"), "a lone single quote must survive").toBe(0); + expect(Len(parsedEnv["WHEELS_SPEC_LONE_DQUOTE"])).toBe(1); + expect(Compare(ToString(parsedEnv["WHEELS_SPEC_LONE_DQUOTE"]), Chr(34)), "a lone double quote must survive").toBe(0); + + // Parsing continues after the lone-quote lines. + expect(Compare(ToString(parsedEnv["WHEELS_SPEC_AFTER_LONE"]), "still-parsed"), "parsing must continue after a lone quote").toBe(0); + }); + + }); + describe("No copy of the parser may reintroduce the coercing test", () => { it("every .env/settings parser compares booleans as strings", () => { @@ -190,6 +224,38 @@ component extends="wheels.WheelsTest" { } }); + it("every Application.cfc parses numbers with the integer-only regex", () => { + $requireRepoPath("cli/lucli/templates/app/public/Application.cfc"); + // Same path resolution as the guard above. + var specPath = Replace(GetCurrentTemplatePath(), "\", "/", "all"); + var marker = "/vendor/wheels/tests/specs/global/"; + var markerPos = Find(marker, specPath); + expect(markerPos, "this spec must live under #marker# for the parser paths to resolve").toBeGT(0); + var root = Left(specPath, markerPos) & "/"; + + // The four `public/Application.cfc` entries of the parsers array above. + var parsers = [ + "public/Application.cfc", + "cli/lucli/templates/app/public/Application.cfc", + "examples/starter-app/public/Application.cfc", + "examples/tweet/public/Application.cfc" + ]; + + for (var relativePath in parsers) { + var parserPath = root & relativePath; + expect(FileExists(parserPath), "parser '#relativePath#' must exist — move it in this spec too").toBeTrue(); + var source = FileRead(parserPath, "utf-8"); + expect( + source, + "'#relativePath#' must coerce only plain integers (^-?(0|[1-9][0-9]*)$)" + ).toInclude("^-?(0|[1-9][0-9]*)$"); + expect( + source, + "'#relativePath#' still uses the old isNumeric/find('.') check, which mangles 007 and 1e3" + ).notToInclude('isNumeric(local.value) && !find(".", local.value)'); + } + }); + }); }