uce/RECOMMENDATIONS.md

21 KiB
Raw Blame History

UCE Code Review — Full Findings (2026-06-11)

Scope: the pending working-tree changes (73 modified tracked files plus new untracked sources — notably src/lib/sqlite-connector.cpp/.h and the uce-starter theme/router rework).

Method: seven independent review angles (line-by-line diff scan, removed-behavior audit, cross-file call tracing, reuse, simplification, efficiency, altitude), followed by a verification pass on the correctness candidates. Status legend:

  • Confirmed — verified against the working tree, decisive lines quoted.
  • Plausible — surfaced by a review angle, not individually re-verified.
  • Refuted — investigated and found not to be an issue (kept here so nobody re-flags it).

Part 1 — Correctness (all confirmed)

1.1 XSS: island props break out of single-quoted attribute

File: site/examples/uce-starter/components/theme/web_affordances.uce:12

The island component emits JSON props inside a single-quoted attribute (data-props='<?: html_escape(payload) ?>'), but html_escape() (src/lib/functionlib.cpp:936) escapes only & < > ", and json_encode / json_escape (src/lib/dtree.cpp:794) never escape apostrophes. Any ' in a prop value terminates the attribute.

Failure: a view passes user-influenced prop text containing an apostrophe, e.g. x' autofocus onfocus='alert(1) — the attribute terminates early and attacker-controlled attributes/event handlers land on the div. Even benign values like Don't silently truncate the payload.

Fix: use a double-quoted attribute (html_escape covers "), or extend html_escape to escape ' as &#39;.

Status: fixed, but fix not verified yet.

1.2 Crash: positional ? placeholders in sqlite_query

File: src/lib/sqlite-connector.cpp:86

bind_params constructs a String directly from sqlite3_bind_parameter_name(stmt, i), which returns NULL for nameless positional ? parameters. std::string(nullptr) is UB and crashes before the if(name == "") continue; guard on the next line can run.

Failure: any page calling sqlite_query(db, "select * from notes where id = ?", params) with a bare ? instead of :name segfaults the worker (500 recovery page).

Fix: remove support for positional placeholders from sqlite and mysql API in favor of named placeholders, adjust the documentation accordingly.

Status: fixed, but fix not verified yet.

1.3 HTTP parsing: split_http_headers assumes lines[0] is the request line

File: src/lib/functionlib.cpp:754

The rewritten parser unconditionally treats the first line as the HTTP request line. The old parser located the first colon-free line, which tolerated a leading CRLF (RFC 7230 §3.5) and header-only input. Two failure modes:

  • The direct-HTTP caller (src/fastcgi/src/fcgicc.cc:693) passes the raw buffer unstripped — a client sending a stray leading CRLF before GET /x.uce HTTP/1.1 gets the request line silently dropped (empty REQUEST_METHOD, request fails).
  • Header-only input (Host: example.test\nX-Token: abc, e.g. page code parsing a raw header block) parses Host: as REQUEST_METHOD and loses HTTP_HOST entirely. The function is directly callable from .uce pages.

Fix: skip leading empty lines before consuming the request line, and only treat a colon-free first line as the request line.

Status: fixed, but fix not verified yet.

1.4 Silent data loss: multi-statement SQL drops everything after the first ;

File: src/lib/sqlite-connector.cpp:178

SQLite::query passes 0 for pzTail to sqlite3_prepare_v2 and never checks for remaining SQL, so multi-statement strings execute only the first statement and still report ok.

Failure: a migration page runs sqlite_query(db, "create table t(id integer); insert into t values(1);") — only the CREATE executes, the INSERT is silently dropped, sqlite_error() says ok, leaving the database half-migrated with no error signal.

Fix: capture pzTail and either loop over remaining statements or raise an error when trailing SQL is present.

Status: fixed, but fix not verified yet.

1.5 Diagnostics regression: fault backtrace captured after siglongjmp

File: src/linux_fastcgi.cpp:895

The in-handler capture_backtrace_string call (request_fault_trace) was deleted; the trace is now captured after siglongjmp back in handle_complete, where the faulting stack has already been unwound. No capture remains in on_request_fault_signal.

Failure: a .uce page null-derefs → SIGSEGV → the error page's Trace shows only handle_complete/main frames. The faulting unit's frames are gone, making crash reports undiagnosable beyond the signal number (the README still advertises a native backtrace of the failure).

Fix: restore the capture inside on_request_fault_signal (on the faulting stack) and hand the result across the longjmp, as the previous code did.

Status: fixed, but fix not verified yet.

1.7 Memory leak: SQLite wrapper objects never freed by request cleanup

File: src/lib/sqlite-connector.cpp:248

cleanup_sqlite_connections() closes the raw sqlite3 handles tracked in resources.sqlite_connections but never deletes the heap-allocated SQLite wrappers from sqlite_connect (new without delete; no arena allocator is compiled in). sqlite_disconnect itself is correct (delete db after unregistering). Note: this mirrors a pre-existing identical leak in the MySQL connector (cleanup_mysql_connections, mysql-connector.cpp:298-304).

Failure: a page calls sqlite_connect() per request and relies on end-of-request cleanup instead of sqlite_disconnect() — exactly what the cleanup path exists for. One wrapper leaks per request; unbounded RSS growth in the long-lived FastCGI worker.

Fix: track the wrapper objects (not raw handles) in resources.sqlite_connections and delete them in cleanup; fix the MySQL connector the same way, or factor a shared registry (see 5.1).

Status: fixed, but fix not verified yet.

1.8 Asset shims emit stylesheets mid-<body>

File: site/examples/uce-starter/components/example/marketing_assets.uce:8 (also theme_assets.uce, gauges/assets.uce)

The new ONCE-based asset shims fire inside the view's ob_start() capture in index.uce (lines 67-85: view is captured into fragments["main"], then themes/page renders), so their <link rel="stylesheet"> output is baked into the main fragment and spliced into the content div — inside <body>, not <head>.

Failure: every marketing/theme/gauges page ships its stylesheet mid-body (FOUC, invalid-ish markup). The deleted page_shell asset registry rendered these in <head>. While this behavior is often okay in practice, we should improve on this to encourage cleaner output.

Fix: Part A: restore a registration mechanism: record component and asset output that's intended as once per page in context.call["fragments"]["once"] by default and the page template component can then explicitly slot this in where appropriate.

Part B: Modify the preprocessor so directives support attributes like this: ONCE(Request& context) @fragment my-fragment-name { ... }

Which will then automatically (in this example) slot the output into context.call["fragments"]["my-fragment-name"] instead of the default slot name "once". In the future we'll introduce more attributes with this syntax. Using this flexible mechanism for the fragment slot name, we can leave it up to the page template where to slot in what. ONCE, COMPONENT, and RENDER should support the fragment attribute.

Status: fixed, but fix not verified yet.

1.9 Error page "Generated C++" hint prints a nonexistent path

File: src/linux_fastcgi.cpp:79

render_request_failure computes the hint as path_join(BIN_DIRECTORY, SCRIPT_FILENAME) + ".cpp", but path_join returns an absolute child unchanged (src/lib/sys.cpp:179), while the compiler builds the real path by plain concatenation BIN_DIRECTORY + src_path (src/lib/compiler.cpp:631-636). SCRIPT_FILENAME is always absolute in the FastCGI path.

Failure: every runtime failure page prints e.g. Generated C++: /var/www/site/page.uce.cpp — the BIN_DIRECTORY prefix is silently dropped and the path never exists (real file: /var/cache/uce/work/var/www/site/page.uce.cpp).

Fix: export one helper from compiler.cpp that maps a source file to its generated artifact (su->pre_path + "/" + su->pre_file_name — the new compiler_format_compile_failure in this same changeset already computes it correctly) and use it in both places.

Status: fixed, but fix not verified yet.

1.10 O(n²) and per-row deep copies in dtree_map / dtree_filter

File: src/lib/functionlib.cpp:182

Both helpers call tree.is_list() inside the per-element callback — is_list() (src/lib/dtree.cpp:165) walks the entire map, making the operation O(n²) — even though both already compute is_list() once before the loop. Additionally, DTree::each (dtree.h:22) takes the callback element by value (DTree t), deep-copying every subtree per iteration.

Failure: mapping over a 1000-row sqlite_query result does ~1M key-validation checks plus a full deep copy of each row tree.

Fix: hoist is_list() into a bool local reused in the callback; change each() to pass const DTree&.

Status: fixed, but fix not verified yet.

1.11 Proactive compiler child can std::terminate on lock failure

File: src/lib/compiler.cpp:288

compiler_with_registry_lock now throws std::runtime_error when the registry lock file can't be opened (previously: warning + proceed unlocked). run_proactive_compiler (src/linux_fastcgi.cpp:1270-1372) calls compiler_list_known_units / compiler_set_known_units with no try/catch anywhere in the chain, so a transient failure (fd exhaustion, disk full) aborts the forked child via std::terminate.

Blast radius is small: the proactive compiler runs in a forked child that the main loop respawns each iteration, and request-path callers are protected by the try/catch in handle_complete. Still, a catch-and-log around the proactive scan is cheap insurance.

Status: fixed, but fix not verified yet.


Part 2 — Reuse / duplication (plausible unless noted)

2.1 request_query_path() duplicates request_query_route()

File: src/lib/uri.cpp:311

request_query_path() copy-pastes request_query_route()'s (line 325) first-keyless-segment scan verbatim — two identical "find first &-part without =" loops plus route_path_sanitize calls that must be kept in sync. It also has zero callers in src/ or site/ (only a doc page references it).

Fix: implement as return request_query_route(context, default_path)["l_path"].to_string();.

Status: fixed, but fix not verified yet.

2.2 list_filter / list_map re-implement the generic filter / map templates

File: src/lib/functionlib.cpp:71

StringList is typedef std::vector<String> (types.h:52), so the existing filter<T> template (functionlib.h:68, declared ~10 lines above the new list_* declarations) already does exactly what list_filter does. Two filter implementations now live in the same module; behavior fixes must be applied twice.

Fix: drop it and point the doc page at filter); same consideration for list_map.

Status: fixed, but fix not verified yet.

2.3 Hand-rolled redirects instead of the redirect() helper

Files: site/examples/uce-starter/views/account/login.uce:13, logout.uce:6, profile.uce:8, site/demo/sqlite.uce:21-22

Four call sites inline context.set_status(302, "Found"); context.header["Location"] = ... instead of calling the existing runtime helper redirect(String url, s32 code = 302) (src/lib/uri.cpp:414). This replaced the single app_redirect() helper the changeset deleted.

Note — security aspect refuted (see 6.1): headers are sanitized centrally on write-out, so this is a pure reuse nit, not a vulnerability.

Fix: use redirect(app_link("account/profile", context)) or restore one app_redirect wrapper.

Status: fixed, but fix not verified yet.

2.5 Asset tag boilerplate repeated across ~a dozen files

Files: components/example/marketing_assets.uce:5, components/example/theme_assets.uce, components/gauges/assets.uce, plus inline <link rel="stylesheet" href="<?= app_asset_url(...) ?>" /> blocks in components/theme/head.uce, components/data/widgets.uce, components/workspace/primitives.uce, views/dashboard.uce

About a dozen hand-written copies of the stylesheet/script tag pattern around app_asset_url(); a versioning or attribute change (defer/integrity) touches every file. The marketing and theme shims differ only in one CSS path.

Fix: collapse the three shims into one parameterizable assets component.

Status: fixed, but fix not verified yet.


Part 3 — Simplification (plausible)

3.1 request_query_route() emits a derivable "rejected" flag

File: src/lib/uri.cpp:347

The route tree contains both route["valid"] and route["rejected"] where rejected is exactly !valid, and nothing in the codebase reads "rejected". Redundant derivable state doubles the invariant surface — a future path that sets one flag without the other produces a route tree that lies.

Fix: drop the "rejected" key; callers needing it can write !route["valid"].to_bool().

Status: fixed, but fix not verified yet.

3.2 Starter router carries write-only diagnostic state

File: site/examples/uce-starter/index.uce:38

The router rewrite adds candidate kind/matched fields and context.call["route"]["candidates"]/["resolved"]/["missed"], none of which is read by any component, theme, or test. It also uses dtree_filter to file_exists-stat every candidate after the first match is already found. ~55 lines plus a builder helper replace what the deleted app_resolve_view did with three early-return file_exists checks.

Fix: remove unused parts.

Status: fixed, but fix not verified yet.

File: site/examples/uce-starter/components/example/marketing_assets.uce:8 (and theme_assets.uce)

These are single-<link> shim files with empty COMPONENT bodies, invoked via print(component(...)) of an empty string. Routed views are mutually exclusive per request, so the cross-unit dedup the shims provide can never trigger; they exist only to host one ONCE line at the cost of two extra files and an indirection on every view. dashboard.uce:3-6 already demonstrates the simpler form (ONCE block directly in the view). gauges/assets.uce is the justified case (multiple sibling components per page) and can stay.

Status: fixed, but fix not verified yet.

3.5 Compile-failure artifact file is self-referential

File: src/lib/compiler.cpp:847

compile_shared_unit() overwrites su->compiler_messages with the formatted failure report, then writes that report into compile_output_file_name — the same artifact the report's own "Compile output:" line (compiler_format_compile_failure, line 800) points readers at. No copy of the raw, unformatted compiler output survives for tooling to parse.

Fix: keep raw messages as the stored/recorded artifact content and format only at the print/response boundary.

Status: fixed, but fix not verified yet.


Part 4 — Efficiency (plausible unless noted)

4.1 QUERY_STRING parsed twice per request

File: src/linux_fastcgi.cpp:744

prepare_request_body_maps calls request_populate_context_params (which splits QUERY_STRING on & + uri-decodes inside request_query_route) and then parse_query(QUERY_STRING) re-splits and re-decodes the identical string on the next line. Runs on every HTTP request, CLI invocation, and websocket event. Route params are also computed eagerly for requests that never read ROUTE_*/BASE_URL.

Fix: parse QUERY_STRING once into request.get first and derive the route token from that single pass, or populate the route params lazily.

Status: fixed, but fix not verified yet.

4.2 Repeated normalization in request_populate_context_params

File: src/lib/uri.cpp:351

Per request: route_path_normalize runs three times on the same path (directly, inside route_path_sanitize, and inside route_path_is_safe), request_script_url is computed twice (for SCRIPT_URL and again inside request_base_url), and route_path_normalize (line 255) strips slashes via path = path.substr(1) in a while loop — O(n²) copies per slash run.

Fix: normalize once and pass the normalized string down; compute script_url into a local used by both params; replace the substr loops with find_first_not_of("/") / find_last_not_of("/") and a single substr.

Status: fixed, but fix not verified yet.

4.4 collect_rows rebuilds column-name strings for every row

File: src/lib/sqlite-connector.cpp:118

For R rows × C columns the row loop does R×C sqlite3_column_name calls plus R×C String heap constructions and map inserts keyed on the fresh string, though column names are invariant across rows. A 10k-row, 8-column result builds 80k redundant name strings per query.

Fix: build a std::vector<String> names(column_count) once before the step loop and index it inside (row[names[i]]).

Status: fixed, but fix not verified yet.

4.5 bind_params copies the params map twice and binds SQLITE_TRANSIENT

File: src/lib/sqlite-connector.cpp:95

SQLite::query takes StringMap by value and passes it by value again to bind_params, then binds with SQLITE_TRANSIENT, forcing SQLite to memcpy each value a third time — even though the copied map outlives the statement (it lives until after sqlite3_finalize).

Fix: pass const StringMap& through query()/bind_params and bind with SQLITE_STATIC (or at least drop the two by-value map copies).

Status: fixed, but fix not verified yet.

4.6 Per-request connection opens re-run pragmas; busy_timeout set twice

File: src/lib/sqlite-connector.cpp:15

connect() calls sqlite3_busy_timeout(5000) and then apply_default_pragmas runs PRAGMA busy_timeout = 5000 again (pure duplicate). PRAGMA journal_mode = WAL is persistent in the database file but re-issued on every open. Because cleanup closes all handles at request end, a page like site/demo/sqlite.uce pays sqlite3_open_v2 + 4 pragmas + CREATE TABLE IF NOT EXISTS on every request.

Fix: drop the redundant busy_timeout pragma; cache open connections per worker keyed by path across requests (resetting state at request end) so pragmas and schema checks run once per worker.

Status: fixed, but fix not verified yet.


Part 5 — Altitude / design (plausible unless noted)

5.3 Test suites hand-registered in three parallel lists — already drifting

Files: site/tests/index.uce:15, tests/plugins/uce_site_suite.py

A test page must be registered in three places: the site/tests/*.uce file itself, a site_tests_card() line in index.uce, and a tuple in uce_site_suite.py. The new sqlite.uce was added to all three by hand. The lists have already drifted: site/tests/call_helpers.uce exists on disk but appears in neither index.uce nor any plugin list, and security_headers.uce is covered only by the security-smoke plugin, not the index cards — new suites can silently fall out of the dashboard and/or CI.

Fix: enumerate site/tests/*.uce with ls()/glob in both index.uce and uce_site_suite.py, with title/tags metadata declared once (in the test page or a single shared manifest).

Status: fixed, but fix not verified yet.

5.4 compiler_developer_hints() couples the runtime to clang's English message text

File: src/lib/compiler.cpp:778

The hint table pattern-matches hardcoded English clang diagnostic substrings ("no member named", "expected ';'", ...). A clang version bump, a switch to gcc (COMPILE_SCRIPT is user-configurable server config), or localized diagnostics silently degrades every hint to the generic fallback, and each new error class means hand-extending an if-chain in runtime C++.

Fix: the excerpt mechanism added in the same change (compiler_format_compile_failure's source/generated excerpts + artifact paths) already carries the diagnostic value; make the hints a data-driven table or drop them in favor of the excerpts.

Status: fixed, but fix not verified yet.

5.5 Generated-artifact path computed by hand in two places

File: src/linux_fastcgi.cpp:79 vs src/lib/compiler.cpp:631-636

The deeper-fix framing of 1.9: the moment the compiler's artifact layout or naming changes (hashing, per-config subdirs), any hand-recomputed path goes stale. One exported source-file → artifact-path helper in compiler.cpp, used by both the compile-failure formatter and the runtime failure page.

Status: fixed, but fix not verified yet.