Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ai/wheels/cross-engine-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ createDynamicProxy(consumer, ["java.util.function.Consumer"]);

### `for` Loops Inside `finally` Blocks Miscompile on Lucee 7

Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block. Isolated with minimal probes: bare assignments and function calls inside `finally` compile and run fine; loops do not. One probe shape even produced a JVM `Expecting a stackmap frame` bytecode-verifier error, pointing at a codegen bug in Lucee's `finally`-block compilation.
Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block. Both loop forms are affected — `for (init; cond; step)` and `for (item in collection)`. Isolated with minimal probes: bare assignments and function calls inside `finally` compile and run fine; loops do not. One probe shape even produced a JVM `Expecting a stackmap frame` bytecode-verifier error, pointing at a codegen bug in Lucee's `finally`-block compilation.

```cfm
// WRONG — crashes at runtime on Lucee 7
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo

### Fixed

- Reviewer-nit sweep from the 2026-06 remediation campaign (#2977). Behavior fixes: conditional validations with uppercase word-form operators (`condition="1 EQ 0"`) no longer throw on Adobe CF (`$evaluateLogicalExpression` now lowercases the operator); the `/wheels/cli` mutation gate reads the reload password from the form scope ONLY, so a `?password=...` query string can no longer satisfy the gate while logging the password in access logs; `dbRollback` over the `/wheels/cli` bridge counts applied migrations by tracked `status` instead of the `version <= current` heuristic, so peer-applied versions on a shared dev database no longer skew `steps=N`; the CLI's three HTTP bridge helpers guard against `getErrorStream()` returning Java null on bodiless 4xx/5xx responses (was an NPE surfacing as a useless "null" error); the migrator's per-request column cache keys on the verbatim table name (case-folding let `Authors`/`authors` share a slot on case-sensitive databases); `$getForeignKeys()` throws `Wheels.Migrator.MissingAdapter` instead of silently interpolating an unquoted table name when the adapter is missing; and the dead ISO-date fallback branch in `$convertToString` uses real `\d` regex escapes. Plus assorted stale-docblock/comment updates (#2903 references, `renderWith`/`onlyProvides` enforcement notes, debug-panel guide note) and spec backfills (`waitForText` timeout surface, `$get()` without `request.wheels`, typed-column outlier defaults, conditional spec-state restore) (#2977)
- `app-runner.cfm` now routes both the test-DB swap and the `finally`-restore through `TestDbResolver.applyDataSource()`, which clears `application.wheels.models` so cached model classes re-initialize against the correct datasource. Without the cache clear, models initialized by a prior dev request kept reading and writing the dev database for the entire test run — spec teardowns like `deleteAll()` in `beforeEach` could wipe real dev data. The restore-side clear matters equally: without it, post-test dev requests silently hit the test datasource via classes cached during the run (#2969)
- `mcpHiddenTools()` now structurally appends every `$`-prefixed PUBLIC function discovered via `getMetaData(this)` to the hidden list, in addition to the explicit literal entries. Defense-in-depth: a future `$publicHelper` added without a denylist update can no longer accidentally leak as a callable MCP tool. The literal `$normalizeTestFilter` / `$resolveAppTestDataSource` entries are retained for clarity and the case where LuCLI consults the list before metadata is fully populated; the structural pass de-duplicates and catches additions (#2963).
- Dispatch now caches resolved route-scoped string middleware as application-scope singletons keyed by component path, so stateful middleware (e.g. an in-memory `RateLimiter` registered on a `.scope(path="/api", middleware=[...])`) accumulates state across requests instead of getting a fresh, empty instance per request. `$copyRouteForRequest` shallow-copies the route's `middleware` array instead of `Duplicate()`-ing it so Adobe CF (which clones CFCs inside arrays) doesn't silently reset the cached instances. The preflight-capability boolean is now computed once at `$init` and stored on the Dispatch instance, replacing the per-OPTIONS-request `IsInstanceOf` scan over the global pipeline. Documents the singleton lifecycle contract: middleware components must be safe to share across concurrent requests, which every built-in middleware already is (#2954)
Expand Down
18 changes: 18 additions & 0 deletions cli/lucli/Module.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -6340,6 +6340,12 @@ component extends="modules.BaseModule" {

var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
// getErrorStream() returns Java null on a bodiless 4xx/5xx response;
// Scanner.init(null) NPEs on Lucee and surfaces as a useless "null"
// error message (#2947 review, #2977). No body — return empty.
if (isNull(inputStream)) {
return "";
}
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");
var response = "";
while (scanner.hasNextLine()) {
Expand Down Expand Up @@ -6373,6 +6379,12 @@ component extends="modules.BaseModule" {

var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
// getErrorStream() returns Java null on a bodiless 4xx/5xx response;
// Scanner.init(null) NPEs on Lucee and surfaces as a useless "null"
// error message (#2947 review, #2977). No body — return empty.
if (isNull(inputStream)) {
return "";
}
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");
var response = "";
while (scanner.hasNextLine()) {
Expand Down Expand Up @@ -6403,6 +6415,12 @@ component extends="modules.BaseModule" {
// Read response (handle both success and error streams)
var responseCode = conn.getResponseCode();
var inputStream = responseCode >= 400 ? conn.getErrorStream() : conn.getInputStream();
// getErrorStream() returns Java null on a bodiless 4xx/5xx response;
// Scanner.init(null) NPEs on Lucee and surfaces as a useless "null"
// error message (#2947 review, #2977). No body — return empty.
if (isNull(inputStream)) {
return "";
}
var scanner = createObject("java", "java.util.Scanner").init(inputStream, "UTF-8");
var response = "";
while (scanner.hasNextLine()) {
Expand Down
10 changes: 7 additions & 3 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2880,9 +2880,13 @@ return local.$wheels;
// fallback parsing attempts for common formats

// 1) ISO YYYY-MM-DD[ hh[:mm[:ss]]]
if (ReFind("(?i)^(\\d{4})-(\\d{2})-(\\d{2})(?:[ T](\\d{1,2}):(\\d{2})(?::(\\d{2}))?)?$", local.s2)) {
local.parts = ReReplace(local.s2, "^(\\d{4})-(\\d{2})-(\\d{2}).*$", "\\1-\\2-\\3", "all");
local.timePart = ReReplace(local.s2, ".*[ T](\\d{1,2}:\\d{2}(?::\\d{2})?).*$", "\\1", "all");
// Single-backslash escapes: in CFML "\\d" is a literal
// backslash + d in the compiled regex, which never matches a
// digit — the branch was dead. Mirrors the already-fixed
// slash-format branch below (#2933 carry-forward, #2977).
if (ReFind("(?i)^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$", local.s2)) {
local.parts = ReReplace(local.s2, "^(\d{4})-(\d{2})-(\d{2}).*$", "\1-\2-\3", "all");
local.timePart = ReReplace(local.s2, ".*[ T](\d{1,2}:\d{2}(?::\d{2})?).*$", "\1", "all");
if (Len(local.timePart) AND local.timePart NEQ local.s2) {
// has time
local.dt = ParseDateTime(local.parts & " " & local.timePart);
Expand Down
5 changes: 3 additions & 2 deletions vendor/wheels/Public.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,9 @@ component output="false" displayName="Internal GUI" extends="wheels.Global" {

/**
* Returns a struct { packages: [...], error: "" } populated from the
* wheels-packages registry. Short-circuits in production (defense in
* depth — the handler is already $blockInProduction()-gated). Captures
* wheels-packages registry. Short-circuits outside development (defense in
* depth — the handler is already $blockInProduction()-gated, which since
* #2903 is a development-only allowlist). Captures
* any registry error into the `error` field so the view can render a
* friendly banner instead of a stack trace.
*
Expand Down
3 changes: 3 additions & 0 deletions vendor/wheels/controller/provides.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ component {
/**
* Use this in an individual controller action to define which formats the action will respond with.
* This can be used to define provides behavior in individual actions or to override a global setting set with `provides` in the controller's `config()`.
* Restrictions are enforced (since 4.0.4): `renderWith()` falls back to the `html` view for a
* format outside the list, and the automatic render in `$callAction()` skips view rendering for
* non-acceptable, non-html formats.
*
* [section: Controller]
* [category: Provides Functions]
Expand Down
3 changes: 3 additions & 0 deletions vendor/wheels/controller/rendering.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ component {
* Instructs the controller to render the data passed in to the format that is requested.
* If the format requested is `json` or `xml`, Wheels will transform the data into that format automatically.
* For other formats (or to override the automatic formatting), you can also create a view template in this format: `nameofaction.xml.cfm`, `nameofaction.json.cfm`, `nameofaction.pdf.cfm`, etc.
* Per-action format restrictions set with `onlyProvides()` are enforced here (since 4.0.4):
* when the requested format is not acceptable for the action, `renderWith()` falls back to
* rendering the `html` view — even when `html` itself is not in the `onlyProvides()` list.
*
* [section: Controller]
* [category: Rendering Functions]
Expand Down
16 changes: 14 additions & 2 deletions vendor/wheels/migrator/Base.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,16 @@ component extends="wheels.Global"{
// rather than assigning a bare local inside catch: BoxLang discards
// `local.X = ...` assignments made in a catch body.)
local.state = {tableExists = true};
local.quotedTable = StructKeyExists(this, "adapter") ? this.adapter.quoteTableName(arguments.table) : arguments.table;
// Migration.init() always sets this.adapter, so a missing adapter is a
// broken instantiation — fail loudly rather than silently interpolating
// an UNQUOTED table name into SQL (#2937 review, #2977).
if (!StructKeyExists(this, "adapter")) {
Throw(
type = "Wheels.Migrator.MissingAdapter",
message = "$getForeignKeys() requires an initialized database adapter. Instantiate migrations through Migration.init()."
);
}
local.quotedTable = this.adapter.quoteTableName(arguments.table);
try {
$query(
datasource = application[local.appKey].dataSourceName,
Expand Down Expand Up @@ -206,7 +215,10 @@ component extends="wheels.Global"{
// would otherwise issue a full table-metadata round-trip per row.
// $execute() drops the cache whenever a statement runs, so DDL in the
// same request (addColumn() etc.) is reflected on the next read.
local.cacheKey = LCase(application[local.appKey].dataSourceName & "|" & arguments.tableName);
// Key on the VERBATIM table name: the $dbinfo probe below uses original
// case, so case-folding the key would let `Authors` and `authors` share
// one slot on case-sensitive databases (#2937 review, #2977).
local.cacheKey = application[local.appKey].dataSourceName & "|" & arguments.tableName;
if (
StructKeyExists(request, "$wheelsMigratorColumns")
&& StructKeyExists(request.$wheelsMigratorColumns, local.cacheKey)
Expand Down
7 changes: 7 additions & 0 deletions vendor/wheels/migrator/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ The flag is read via `$get("useUnderscoreReferenceColumns")` inside `references(
2. **Hard-coding `& "id"` or `& "type"` concatenations.** All four sites in this directory resolve the reference-column suffix through `$get("useUnderscoreReferenceColumns")` — `TableDefinition.cfc::references()` (id + polymorphic type), `Migration.cfc::removeColumn` (referenceName branch), and `Migration.cfc::addReference`. If you add new code that builds a reference column name, route it through `$get` too rather than hard-coding `& "id"`.
3. **`required` on column-name parameters.** Use `$combineArguments(... required=true)` instead. Declaring CFML-level `required` blocks the alias path because validation runs before the function body.

## Internal caches

Two caches introduced in #2937 — know their scopes before adding probes:

- `application[appKey].$migratorAdapterNames` — application-scoped, keyed by datasource name. Memoized migrator adapter name, written by `Base.cfc::$getDBType()`. Survives requests; rebuilt on reload (a datasource's driver can't change without one).
- `request.$wheelsMigratorColumns` — request-scoped, keyed by `dsName|tableName` (table name VERBATIM — no case folding, since the `$dbinfo` probe uses original case and case-sensitive databases can host `Authors` and `authors` separately). Column list per table, written by `Base.cfc::$getColumns()`, dropped wholesale by `$execute()` so DDL in the same request is reflected on the next read.

## Tests

Specs live in `vendor/wheels/tests/specs/migrator/`. `referencesSpec.cfc` exercises `TableDefinition::references()` (the `columnNames` alias plus the suffix flag) at the unit layer — inspecting `t.columns` / `t.foreignKeys` directly without `t.create()` so the assertions are adapter-independent. `primaryKeySpec.cfc` mirrors that shape for `TableDefinition::primaryKey()` — the `columnName` / `columnNames` aliases plus precedence semantics (#2803). `migrationSpec.cfc` covers Migration.cfc command-version helpers via real DDL roundtrips — its "Tests addReference" describe block guards the `useUnderscoreReferenceColumns` path on `Migration.cfc::addReference()`. Most FK-related tests in `migrationSpec.cfc` skip on SQLite (which doesn't support altering CONSTRAINTS) but run on every other engine in CI.
Expand Down
6 changes: 5 additions & 1 deletion vendor/wheels/model/validations.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,11 @@ component {
}
local.leftOperand = IsNumeric(local.tokens[1]) ? JavaCast("double", local.tokens[1]) : local.tokens[1];
local.rightOperand = IsNumeric(local.tokens[3]) ? JavaCast("double", local.tokens[3]) : local.tokens[3];
return $resolveOperator(local.leftOperand, local.rightOperand, local.tokens[2]);
// LCase keeps word-form operators ("1 EQ 0") compatible with the
// case-sensitive switch in $resolveOperator on Adobe CF — symbolic
// operators are already lowercased by $normalizeConditionOperators,
// but word-form ones arrive raw (#2977).
return $resolveOperator(local.leftOperand, local.rightOperand, LCase(local.tokens[2]));
}

/**
Expand Down
13 changes: 11 additions & 2 deletions vendor/wheels/public/views/cli.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ try {
requestMethod = cgi.request_method,
remoteAddr = cgi.remote_addr,
forwardedFor = cgi.http_x_forwarded_for,
password = StructKeyExists(request.wheels.params, "password") ? request.wheels.params.password : ""
// Form scope ONLY: request.wheels.params merges URL + form, so a
// ?password=... query string would satisfy the gate while logging
// the reload password in access logs / proxies — contradicting the
// SEC-4 design of carrying it as a form field (#2947 review, #2977).
password = StructKeyExists(form, "password") ? form.password : ""
);
if (!local.gate.allowed) {
cfheader(statuscode = local.gate.statusCode);
Expand Down Expand Up @@ -318,9 +322,14 @@ try {

// Find target version based on steps. Reuses the list
// discovered in the preamble instead of re-discovering.
// Filter on tracked status, not version <= current: on a shared
// dev DB a peer-applied version above your latest local file
// made the version heuristic count pending/orphan rows as
// applied, so `steps=N` rolled back fewer real migrations
// (same P3 fix dbStatus got in #2947; #2977).
local.appliedMigrations = [];
for (local.migration in data.migrations) {
if (local.migration.version <= data.currentVersion) {
if (local.migration.status == "migrated") {
arrayAppend(local.appliedMigrations, local.migration);
}
}
Expand Down
11 changes: 7 additions & 4 deletions vendor/wheels/tests/specs/dispatch/InvokeMethodSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ component extends="wheels.WheelsTest" {
it("invokes a Public.cfc instance without throwing on $blockInProduction", function() {
// End-to-end shape of the dispatch flow at Dispatch.cfc:287.
// We don't actually serve a request — we just verify the
// adapter can invoke a Public.cfc handler. In non-production
// environments $blockInProduction() short-circuits to a no-op,
// so the only thing we're testing is "did the receiver survive
// adapter can invoke a Public.cfc handler. In the development
// environment $blockInProduction() short-circuits to a no-op
// (since #2903 the gate is a development-only allowlist), so
// the only thing we're testing is "did the receiver survive
// the dispatch?" If it didn't, the call throws before the
// include statement runs.
// include statement runs. (This spec invokes the ungated
// index() handler, so the production-only early-return below
// is belt-and-suspenders.)
if (
StructKeyExists(application, "wheels")
&& StructKeyExists(application.wheels, "environment")
Expand Down
33 changes: 33 additions & 0 deletions vendor/wheels/tests/specs/global/getSettingRequestScopeSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Regression surface of the DC16 fix (#2933): $get()'s per-tenant override
* lookup traverses request.wheels.tenant.config via a StructKeyExists chain.
* The exact hazard the IsDefined→StructKeyExists rewrite guarded against is
* an ABSENT request.wheels (early bootstrap, CLI call sites) — the happy
* tenant-override paths are covered by MultiTenantIntegrationSpec; this
* pins the no-throw contract for the absent case (#2977).
*/
component extends="wheels.WheelsTest" {

function run() {

describe("$get() without request.wheels", () => {

it("does not throw when request.wheels is absent", () => {
var had = StructKeyExists(request, "wheels");
var saved = had ? request.wheels : {};
StructDelete(request, "wheels");
try {
var value = application.wo.$get("environment");
expect(value).toBe(application.wheels.environment);
} finally {
if (had) {
request.wheels = saved;
}
}
});

});

}

}
21 changes: 17 additions & 4 deletions vendor/wheels/tests/specs/global/loadRoutesSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,27 @@ component extends="wheels.WheelsTest" {

function beforeAll() {
_originalRoutes = Duplicate(application.wheels.routes)
_originalStaticRoutes = StructKeyExists(application.wheels, "staticRoutes") ? StructCopy(application.wheels.staticRoutes) : {}
_originalNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions") ? StructCopy(application.wheels.namedRoutePositions) : {}
_hadStaticRoutes = StructKeyExists(application.wheels, "staticRoutes")
_originalStaticRoutes = _hadStaticRoutes ? StructCopy(application.wheels.staticRoutes) : {}
_hadNamedRoutePositions = StructKeyExists(application.wheels, "namedRoutePositions")
_originalNamedRoutePositions = _hadNamedRoutePositions ? StructCopy(application.wheels.namedRoutePositions) : {}
}

function afterAll() {
application.wheels.routes = _originalRoutes
application.wheels.staticRoutes = _originalStaticRoutes
application.wheels.namedRoutePositions = _originalNamedRoutePositions
// Restore only what existed: an unconditional assignment would leave a
// spurious empty key behind when the spec ran before the app ever
// populated these caches (#2933 review, #2977).
if (_hadStaticRoutes) {
application.wheels.staticRoutes = _originalStaticRoutes
} else {
StructDelete(application.wheels, "staticRoutes")
}
if (_hadNamedRoutePositions) {
application.wheels.namedRoutePositions = _originalNamedRoutePositions
} else {
StructDelete(application.wheels, "namedRoutePositions")
}
}

function run() {
Expand Down
Loading
Loading