diff --git a/.gitignore b/.gitignore index 02275c9..6143a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ node_modules/ /public/vendor/opencascade/ /tools/renderer-step/build/ **.log +# Python tooling bytecode caches. +__pycache__/ +*.pyc # Local databases (SQLite projection + Hydrant fjall store). /data/ /hydrant.db/ diff --git a/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json b/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json new file mode 100644 index 0000000..5a176b0 --- /dev/null +++ b/.sqlx/query-0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "0937d376f0b5774aee42d246559cd32ab68fa1662e796c3cf592285285551b7c" +} diff --git a/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json b/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json new file mode 100644 index 0000000..f8a5c1d --- /dev/null +++ b/.sqlx/query-0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, ?, 0)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "0e99368d1deb5983378e91966b91a8573669d421029964a768044f45d9ce0536" +} diff --git a/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json b/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json new file mode 100644 index 0000000..2e73504 --- /dev/null +++ b/.sqlx/query-111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO profiles (did, display_name, description, avatar_json, default_license,\n pronouns, printers_json, links_json, record_json, indexed_at)\n VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "111f07c8f230de39ff2c06967435622dd37ace1548fea17eb57fe1d55bcd73bd" +} diff --git a/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json b/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json new file mode 100644 index 0000000..f8c1ed1 --- /dev/null +++ b/.sqlx/query-78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,\n tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "78063f22dc67e546f0822eb5e1febe849023eb2d790b062342c6d853c9f7258c" +} diff --git a/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json b/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json new file mode 100644 index 0000000..1a19f3d --- /dev/null +++ b/.sqlx/query-82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, 42, 12, 3)", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "82ad9d19691c9c2a7f68599d52302eff6a64c5e38cfb0255c21d4a55c6f99d8d" +} diff --git a/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json b/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json new file mode 100644 index 0000000..c05c0d6 --- /dev/null +++ b/.sqlx/query-9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 10 + }, + "nullable": [] + }, + "hash": "9d3ae9004dcf483e658e3da44d52bae0284416437e5e39fcef7f1536d820bad3" +} diff --git a/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json b/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json new file mode 100644 index 0000000..f4a6b4b --- /dev/null +++ b/.sqlx/query-b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "b5def9780ec5f6802df046e8761999a329b310a341c35260ef101b9d40c901b8" +} diff --git a/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json b/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json new file mode 100644 index 0000000..7ac1386 --- /dev/null +++ b/.sqlx/query-ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "ccc3ede0dfc9b961386b10491195656b5589b0dd880e58444b8f6c0adf246668" +} diff --git a/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json b/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json new file mode 100644 index 0000000..8445622 --- /dev/null +++ b/.sqlx/query-dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json,\n tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "dd588b2a5f3650d72db321af63c54ec94684eb9f59480ff6fe27f2885a3a90bf" +} diff --git a/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json b/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json new file mode 100644 index 0000000..59db16d --- /dev/null +++ b/.sqlx/query-e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "e28ec474510b41426db6da080faa94e0dd662048bf8caf7ea75f6561ebd819b5" +} diff --git a/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json b/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json new file mode 100644 index 0000000..a0eb51e --- /dev/null +++ b/.sqlx/query-fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(*) FROM things", + "describe": { + "columns": [ + { + "name": "COUNT(*)", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false + ] + }, + "hash": "fa70a2929db5194d48a34173e6f1292182911f65972d1fe58cac0d3e0c212e4c" +} diff --git a/justfile b/justfile index dab0258..4577957 100644 --- a/justfile +++ b/justfile @@ -28,6 +28,10 @@ check: cargo check -p polymodel --target wasm32-unknown-unknown --features web RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo check -p polymodel-renderer-worker --target wasm32-unknown-unknown +# Enforce compile-time SQL macros in production Rust sources only. +check-production-sql *ARGS: + python3 tools/check-production-sql.py {{ ARGS }} + # Create and migrate the SQLite projection database. # Required when regenerating the `.sqlx` cache, and re-run after editing migrations. migrate: diff --git a/src/indexing/sample_data.rs b/src/indexing/sample_data.rs index 8407728..1a6fee8 100644 --- a/src/indexing/sample_data.rs +++ b/src/indexing/sample_data.rs @@ -32,7 +32,7 @@ const FOUNDRY_DID: &str = "did:plc:cccccccccccccccccccccccc"; const FOUNDRY_HANDLE: &str = "foundrylab.tools"; pub async fn seed_if_empty(pool: &SqlitePool) -> anyhow::Result<()> { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM things") + let count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM things") .fetch_one(pool) .await?; if count > 0 { @@ -113,41 +113,43 @@ async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { let thing_record_json = typed_json::(thing_record.clone())?; - sqlx::query( + sqlx::query!( r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)"#, + SAMPLE_DID, + SAMPLE_THING_RKEY, + SAMPLE_THING_URI, + "bafyreipolymodelsamplething", + "Parametric enclosure kit", + "A configurable electronics enclosure kit with three printable model variants and detailed part files.", + "CC-BY-4.0", + serde_json::to_string(&tags)?, + tags.join(" "), + instructions.join("\n"), + serde_json::to_string(&thing_record["cover"])? , + SAMPLE_TIME_MILLIS, + SAMPLE_TIME_MILLIS, + thing_record_json, ) - .bind(SAMPLE_DID) - .bind(SAMPLE_THING_RKEY) - .bind(SAMPLE_THING_URI) - .bind("bafyreipolymodelsamplething") - .bind("Parametric enclosure kit") - .bind("A configurable electronics enclosure kit with three printable model variants and detailed part files.") - .bind("CC-BY-4.0") - .bind(serde_json::to_string(&tags)?) - .bind(tags.join(" ")) - .bind(instructions.join("\n")) - .bind(serde_json::to_string(&thing_record["cover"])? ) - .bind(SAMPLE_TIME_MILLIS) - .bind(SAMPLE_TIME_MILLIS) - .bind(thing_record_json) .execute(&mut *tx) .await?; for (position, model_uri) in model_uris.iter().enumerate() { - sqlx::query("INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)") - .bind(SAMPLE_THING_URI) - .bind(model_uri) - .bind(position as i64) - .execute(&mut *tx) - .await?; + sqlx::query!( + "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", + SAMPLE_THING_URI, + model_uri, + position as i64, + ) + .execute(&mut *tx) + .await?; } - sqlx::query( + sqlx::query!( "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, 42, 12, 3)", + SAMPLE_THING_URI, ) - .bind(SAMPLE_THING_URI) .execute(&mut *tx) .await?; @@ -185,12 +187,14 @@ async fn insert_identity( did: &str, handle: &str, ) -> anyhow::Result<()> { - sqlx::query("INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)") - .bind(did) - .bind(handle) - .bind(SAMPLE_TIME_MILLIS) - .execute(&mut *conn) - .await?; + sqlx::query!( + "INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)", + did, + handle, + SAMPLE_TIME_MILLIS, + ) + .execute(&mut *conn) + .await?; Ok(()) } @@ -203,19 +207,19 @@ async fn insert_profile(conn: &mut SqliteConnection) -> anyhow::Result<()> { "pronouns": "she/her", "defaultLicense": "CC-BY-4.0" }))?; - sqlx::query( + sqlx::query!( r#"INSERT INTO profiles (did, display_name, description, avatar_json, default_license, pronouns, printers_json, links_json, record_json, indexed_at) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)"#, + SAMPLE_DID, + "Ari Chen", + "Maker of practical parametric fixtures and electronics housings.", + &avatar, + "CC-BY-4.0", + "she/her", + profile_record, + SAMPLE_TIME_MILLIS, ) - .bind(SAMPLE_DID) - .bind("Ari Chen") - .bind("Maker of practical parametric fixtures and electronics housings.") - .bind(&avatar) - .bind("CC-BY-4.0") - .bind("she/her") - .bind(profile_record) - .bind(SAMPLE_TIME_MILLIS) .execute(&mut *conn) .await?; Ok(()) @@ -241,29 +245,31 @@ async fn insert_model( "createdAt": SAMPLE_TIME }))?; - sqlx::query( + sqlx::query!( r#"INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + SAMPLE_DID, + rkey, + &uri, + format!("bafyreimodel{rkey}"), + name, + summary, + SAMPLE_TIME_MILLIS, + SAMPLE_TIME_MILLIS, + record, ) - .bind(SAMPLE_DID) - .bind(rkey) - .bind(&uri) - .bind(format!("bafyreimodel{rkey}")) - .bind(name) - .bind(summary) - .bind(SAMPLE_TIME_MILLIS) - .bind(SAMPLE_TIME_MILLIS) - .bind(record) .execute(&mut *conn) .await?; for (position, part) in parts.iter().enumerate() { - sqlx::query("INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)") - .bind(&uri) - .bind(part_uri(part.rkey)) - .bind(position as i64) - .execute(&mut *conn) - .await?; + sqlx::query!( + "INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)", + &uri, + part_uri(part.rkey), + position as i64, + ) + .execute(&mut *conn) + .await?; } Ok(()) } @@ -283,20 +289,20 @@ async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow:: "createdAt": SAMPLE_TIME }))?; - sqlx::query( + sqlx::query!( r#"INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + SAMPLE_DID, + part.rkey, + part_uri(part.rkey), + format!("bafyreipart{}", part.rkey), + part.name, + "STL", + file_json, + SAMPLE_TIME_MILLIS, + SAMPLE_TIME_MILLIS, + record, ) - .bind(SAMPLE_DID) - .bind(part.rkey) - .bind(part_uri(part.rkey)) - .bind(format!("bafyreipart{}", part.rkey)) - .bind(part.name) - .bind("STL") - .bind(file_json) - .bind(SAMPLE_TIME_MILLIS) - .bind(SAMPLE_TIME_MILLIS) - .bind(record) .execute(&mut *conn) .await?; Ok(()) @@ -361,31 +367,31 @@ async fn insert_coverless_thing( "createdAt": SAMPLE_TIME }))?; - sqlx::query( + sqlx::query!( r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?)"#, + did, + rkey, + &uri, + format!("bafyreithing{}", rkey.replace('-', "")), + name, + summary, + "CC-BY-4.0", + SAMPLE_TIME_MILLIS, + SAMPLE_TIME_MILLIS, + record, ) - .bind(did) - .bind(rkey) - .bind(&uri) - .bind(format!("bafyreithing{}", rkey.replace('-', ""))) - .bind(name) - .bind(summary) - .bind("CC-BY-4.0") - .bind(SAMPLE_TIME_MILLIS) - .bind(SAMPLE_TIME_MILLIS) - .bind(record) .execute(&mut *conn) .await?; if like_count > 0 || save_count > 0 { - sqlx::query( + sqlx::query!( "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, ?, ?, 0)", + &uri, + like_count, + save_count, ) - .bind(&uri) - .bind(like_count) - .bind(save_count) .execute(&mut *conn) .await?; } diff --git a/tools/check-production-sql.py b/tools/check-production-sql.py new file mode 100644 index 0000000..761b4fb --- /dev/null +++ b/tools/check-production-sql.py @@ -0,0 +1,602 @@ +#!/usr/bin/env python3 +"""Reject runtime SQLx query calls in production Rust sources. + +The production policy applies to handwritten Rust under ``src/``. Explicitly +owned test files and test-only module bodies are outside this check because +their dynamic SQL is fixture/setup code rather than application SQL. Files such +as ``contest.rs`` remain production source; a substring in a filename is not +test ownership. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +# Comments are blanked before this expression runs, so ``::`` may be separated +# from either identifier by whitespace or comments. The optional turbofish is +# deliberately accepted only before a call parenthesis; ``query!`` therefore +# remains a compile-time macro and is not reported. +RUNTIME_CALL = re.compile( + r"\bsqlx\s*::\s*query(?:_as|_scalar)?\s*" + r"(?:::\s*<[^;{}\n]*>\s*)?\(" +) +CFG_ATTRIBUTE_START = re.compile(r"#\s*\[\s*cfg\s*\(") + + +def _skip_rust_token(source: str, index: int) -> int | None: + """Return the end of a comment/string/char token at ``index``.""" + if source.startswith("//", index): + newline = source.find("\n", index + 2) + return len(source) if newline == -1 else newline + + if source.startswith("/*", index): + depth = 1 + cursor = index + 2 + while cursor < len(source) and depth: + if source.startswith("/*", cursor): + depth += 1 + cursor += 2 + elif source.startswith("*/", cursor): + depth -= 1 + cursor += 2 + else: + cursor += 1 + return cursor + + raw_prefix_end: int | None = None + if source.startswith("br", index): + raw_prefix_end = index + 2 + elif source.startswith("r", index): + raw_prefix_end = index + 1 + if raw_prefix_end is not None: + cursor = raw_prefix_end + while cursor < len(source) and source[cursor] == "#": + cursor += 1 + if cursor < len(source) and source[cursor] == '"': + hashes = cursor - raw_prefix_end + closing = '"' + ("#" * hashes) + end = source.find(closing, cursor + 1) + return len(source) if end == -1 else end + len(closing) + + quote_index: int | None = None + if source[index] == '"': + quote_index = index + elif index + 1 < len(source) and source[index] in "bc" and source[index + 1] == '"': + quote_index = index + 1 + if quote_index is not None: + cursor = quote_index + 1 + escaped = False + while cursor < len(source): + char = source[cursor] + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + return cursor + 1 + cursor += 1 + return len(source) + + if source[index] == "'": + cursor = index + 1 + if cursor >= len(source): + return None + if source[cursor] == "\\": + cursor += 2 + if source[index + 1 : index + 3] == "\\u" and cursor < len(source) and source[cursor] == "{": + closing = source.find("}", cursor + 1) + cursor = len(source) if closing == -1 else closing + 1 + else: + cursor += 1 + if cursor < len(source) and source[cursor] == "'": + return cursor + 1 + + return None + + +def _is_code_position(source: str, target: int) -> bool: + """Return whether ``target`` is outside comments, strings, and chars.""" + index = 0 + while index < target: + end = _skip_rust_token(source, index) + if end is not None: + if index <= target < end: + return False + index = end + else: + index += 1 + return True + + +def _find_identifier(source: str, start: int, wanted: str) -> int | None: + index = start + while index < len(source): + end = _skip_rust_token(source, index) + if end is not None: + index = end + continue + if source.startswith(wanted, index): + before = source[index - 1] if index else " " + after_index = index + len(wanted) + after = source[after_index] if after_index < len(source) else " " + if not (before.isalnum() or before == "_") and not ( + after.isalnum() or after == "_" + ): + return index + index += 1 + return None + + +def _find_next_code_char(source: str, start: int, wanted: str) -> int | None: + index = start + while index < len(source): + end = _skip_rust_token(source, index) + if end is not None: + index = end + elif source[index] == wanted: + return index + else: + index += 1 + return None + + +def _find_matching_brace(source: str, body_start: int) -> int | None: + depth = 0 + index = body_start + while index < len(source): + end = _skip_rust_token(source, index) + if end is not None: + index = end + continue + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return index + 1 + index += 1 + return None + + +def _matching_delimiter(source: str, opening: int, left: str, right: str) -> int | None: + """Find a matching delimiter while ignoring comments and literals.""" + depth = 0 + index = opening + while index < len(source): + end = _skip_rust_token(source, index) + if end is not None: + index = end + continue + if source[index] == left: + depth += 1 + elif source[index] == right: + depth -= 1 + if depth == 0: + return index + index += 1 + return None + + +def _find_item_terminator(source: str, start: int) -> int | None: + """Return the end of a const/static item, ignoring nested delimiters.""" + pairs = {"(": ")", "[": "]", "{": "}"} + closing = {right: left for left, right in pairs.items()} + stack: list[str] = [] + index = start + while index < len(source): + end = _skip_rust_token(source, index) + if end is not None: + index = end + continue + char = source[index] + if char in pairs: + stack.append(char) + elif char in closing: + if stack and stack[-1] == closing[char]: + stack.pop() + elif char == ";" and not stack: + return index + 1 + index += 1 + return None + + +def _cfg_truth_without_test(expression: str) -> bool | None: + """Evaluate a cfg expression with ``test`` disabled when provable. + + ``None`` means that an unknown predicate may be either true or false. This + three-valued evaluation keeps negation conservative: only expressions + proven false are masked from the production SQL scan. + """ + expression = expression.strip() + if expression == "test": + return False + if expression.startswith("all(") and expression.endswith(")"): + values = [ + _cfg_truth_without_test(part) + for part in _split_cfg_arguments(expression[4:-1]) + ] + if any(value is False for value in values): + return False + if all(value is True for value in values): + return True + return None + if expression.startswith("any(") and expression.endswith(")"): + values = [ + _cfg_truth_without_test(part) + for part in _split_cfg_arguments(expression[4:-1]) + ] + if any(value is True for value in values): + return True + if all(value is False for value in values): + return False + return None + if expression.startswith("not(") and expression.endswith(")"): + value = _cfg_truth_without_test(expression[4:-1]) + return None if value is None else not value + return None + + +def _cfg_can_be_true_without_test(expression: str) -> bool: + """Return whether a cfg expression can match with ``test`` disabled.""" + return _cfg_truth_without_test(expression) is not False + + +def _split_cfg_arguments(expression: str) -> list[str]: + parts: list[str] = [] + start = 0 + depth = 0 + index = 0 + while index < len(expression): + end = _skip_rust_token(expression, index) + if end is not None: + index = end + continue + char = expression[index] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + elif char == "," and depth == 0: + parts.append(expression[start:index].strip()) + start = index + 1 + index += 1 + parts.append(expression[start:].strip()) + return [part for part in parts if part] + + +def _skip_space(source: str, index: int) -> int: + while index < len(source) and source[index].isspace(): + index += 1 + return index + + +def _identifier_at(source: str, index: int, identifier: str) -> int | None: + if not source.startswith(identifier, index): + return None + before = source[index - 1] if index else " " + after_index = index + len(identifier) + after = source[after_index] if after_index < len(source) else " " + if before.isalnum() or before == "_" or after.isalnum() or after == "_": + return None + return after_index + + +def _attributed_item_body(code: str, attribute_start: int, attribute_end: int) -> tuple[int, int] | None: + """Return ``(attribute_start, body_end)`` for a cfg-attributed item body.""" + index = _skip_space(code, attribute_end) + # Rust permits a stack of attributes before the item. Consume only complete + # attributes immediately adjacent to this item; never search ahead for an + # unrelated declaration. + while index < len(code) and code.startswith("#[", index): + closing = _matching_delimiter(code, index + 1, "[", "]") + if closing is None: + return None + index = _skip_space(code, closing + 1) + + if code.startswith("pub", index): + after_pub = _identifier_at(code, index, "pub") + if after_pub is None: + return None + index = _skip_space(code, after_pub) + if index < len(code) and code[index] == "(": + visibility_end = _matching_delimiter(code, index, "(", ")") + if visibility_end is None: + return None + index = _skip_space(code, visibility_end + 1) + + after_mod = _identifier_at(code, index, "mod") + if after_mod is not None: + index = _skip_space(code, after_mod) + name_end = index + while name_end < len(code) and (code[name_end].isalnum() or code[name_end] == "_"): + name_end += 1 + if name_end == index: + return None + index = _skip_space(code, name_end) + if index >= len(code) or code[index] != "{": + # External modules have no body in this file. Their separate file + # is checked by source_paths according to its explicit ownership. + return None + body_end = _find_matching_brace(code, index) + return None if body_end is None else (attribute_start, body_end) + + for item_kind in ("impl", "trait"): + after_item_kind = _identifier_at(code, index, item_kind) + if after_item_kind is None: + continue + body_open = _find_next_code_char(code, after_item_kind, "{") + if body_open is None: + return None + body_end = _find_matching_brace(code, body_open) + return None if body_end is None else (attribute_start, body_end) + + after_const = _identifier_at(code, index, "const") + if after_const is not None: + after_const_name = _skip_space(code, after_const) + # `const fn` is handled by the function path below; other const items + # end at their top-level semicolon, including block initializers. + if _identifier_at(code, after_const_name, "fn") is None: + body_end = _find_item_terminator(code, after_const) + return None if body_end is None else (attribute_start, body_end) + + after_static = _identifier_at(code, index, "static") + if after_static is not None: + body_end = _find_item_terminator(code, after_static) + return None if body_end is None else (attribute_start, body_end) + + # Test helpers are commonly free functions rather than nested modules. + # Consume the ordinary qualifiers before `fn`, then find the function body + # without crossing a declaration terminator. + while True: + qualifier = None + for candidate in ("const", "async", "unsafe"): + end = _identifier_at(code, index, candidate) + if end is not None: + qualifier = end + break + if qualifier is None: + break + index = _skip_space(code, qualifier) + after_fn = _identifier_at(code, index, "fn") + if after_fn is None: + return None + body_open = _find_next_code_char(code, after_fn, "{") + if body_open is None: + return None + terminator = _find_next_code_char(code, after_fn, ";") + if terminator is not None and terminator < body_open: + return None + body_end = _find_matching_brace(code, body_open) + return None if body_end is None else (attribute_start, body_end) + + +def mask_test_modules(source: str) -> str: + """Blank test-only cfg-attributed module and function bodies.""" + code = mask_non_code_tokens(source) + result = list(source) + for match in CFG_ATTRIBUTE_START.finditer(code): + opening = code.find("(", match.start(), match.end()) + if opening == -1: + continue + closing = _matching_delimiter(code, opening, "(", ")") + if closing is None: + continue + bracket = _skip_space(code, closing + 1) + if bracket >= len(code) or code[bracket] != "]": + continue + expression = source[opening + 1 : closing] + if _cfg_can_be_true_without_test(expression): + continue + item = _attributed_item_body(code, match.start(), bracket + 1) + if item is None: + continue + start, body_end = item + for index in range(start, body_end): + if result[index] != "\n": + result[index] = " " + return "".join(result) + + +def mask_non_code_tokens(source: str) -> str: + """Blank comments and literals so regex checks only inspect Rust code.""" + result = list(source) + index = 0 + while index < len(source): + end = _skip_rust_token(source, index) + if end is None: + index += 1 + continue + for token_index in range(index, end): + if result[token_index] != "\n": + result[token_index] = " " + index = end + return "".join(result) + + +def production_source(path: Path) -> str: + source = path.read_text(encoding="utf-8") + return mask_non_code_tokens(mask_test_modules(source)) + + +def _is_explicit_test_owned(path: Path, root: Path) -> bool: + """Recognize conventional test ownership without substring heuristics.""" + relative = path.relative_to(root / "src") + parts = relative.parts + if any(part in {"tests", "test"} for part in parts[:-1]): + return True + return path.stem in {"tests", "test"} or path.name.endswith("_tests.rs") + + +def source_paths(root: Path) -> list[Path]: + src = root / "src" + if not src.is_dir(): + return [] + return sorted( + path + for path in src.rglob("*.rs") + if not _is_explicit_test_owned(path, root) + ) + + +def violations(root: Path) -> list[tuple[Path, int]]: + findings: list[tuple[Path, int]] = [] + for path in source_paths(root): + source = production_source(path) + for match in RUNTIME_CALL.finditer(source): + findings.append((path, source.count("\n", 0, match.start()) + 1)) + return findings + + +def run_self_test() -> None: + with TemporaryDirectory() as directory: + root = Path(directory) + (root / "src" / "tests").mkdir(parents=True) + (root / "migrations").mkdir() + (root / "src" / "production.rs").write_text( + "pub fn seed() {\n" + " sqlx /* path */ :: query /* call */ (\"SELECT 1\");\n" + " sqlx::query_as :: <_, Row>(\"SELECT 2\");\n" + " sqlx::query_scalar!(\"SELECT 3\");\n" + "}\n", + encoding="utf-8", + ) + (root / "src" / "nested.rs").write_text( + "#[cfg(test)]\nmod tests {\n" + " /* outer { /* nested } */ still test */\n" + " let sql = r#\"fixture { cfg(test) }\"#;\n" + " let lifetime: &'a str = 'x';\n" + " sqlx::query(\"fixture\");\n" + "}\n\n" + "#[cfg(test)]\nfn test_function_does_not_mask_following_module() {\n" + " sqlx::query(\"test-only function fixture\");\n" + "}\n" + "mod production_after_test_function {\n" + " sqlx::query_scalar(\"SELECT 4\");\n" + "}\n\n" + "#[cfg(test)]\nmod external_tests;\n\n" + "pub fn checked() { sqlx::query_scalar(\"SELECT 5\"); }\n", + encoding="utf-8", + ) + (root / "src" / "cfg_forms.rs").write_text( + "#[cfg(test)]\n" + "impl TestImpl {\n" + " fn query(&self) {\n" + " sqlx::query(\"masked impl\");\n" + " }\n" + "}\n" + "#[cfg(test)]\n" + "trait TestTrait {\n" + " fn query(&self) {\n" + " sqlx::query(\"masked trait\");\n" + " }\n" + "}\n" + "#[cfg(test)]\n" + "const TEST_CONST: &str = {\n" + " sqlx::query(\"masked const\");\n" + " \"fixture\"\n" + "};\n" + "#[cfg(test)]\n" + "static TEST_STATIC: &str = {\n" + " sqlx::query(\"masked static\");\n" + " \"fixture\"\n" + "};\n" + "#[cfg(all(test, feature = \"fixtures\"))]\n" + "mod all_tests { sqlx::query(\"masked all\"); }\n" + "#[cfg(any(test, feature = \"fixtures\"))]\n" + "mod mixed_tests { sqlx::query(\"reported any\"); }\n" + "#[cfg(not(any(test, feature = \"fixtures\")))]\n" + "mod negated_mixed { sqlx::query(\"reported negated any\"); }\n" + "#[cfg(not(feature = \"fixtures\"))]\n" + "mod negated_feature { sqlx::query(\"reported negated feature\"); }\n", + encoding="utf-8", + ) + (root / "src" / "oauth.rs").write_text( + "#[cfg(test)]\nmod tests {\n" + " let raw = br##\"{ nested \\\"quotes\\\" }\"##;\n" + " sqlx::query_as(\"fixture\");\n" + "}\n", + encoding="utf-8", + ) + (root / "src" / "comments.rs").write_text( + "// #[cfg(test)] mod ignored { sqlx::query(\"comment\"); }\n" + "pub const TEXT: &str = r#\"#[cfg(test)] mod ignored { }\"#;\n", + encoding="utf-8", + ) + (root / "src" / "fixture_tests.rs").write_text( + "sqlx::query_as(\"fixture file\");\n", encoding="utf-8" + ) + (root / "src" / "contest.rs").write_text( + "pub fn production_contest() { sqlx::query(\"contest\"); }\n", + encoding="utf-8", + ) + (root / "migrations" / "0001.sql").write_text( + "sqlx::query(\"migration text\");\n", encoding="utf-8" + ) + + found = violations(root) + expected = [ + (root / "src" / "cfg_forms.rs", 26), + (root / "src" / "cfg_forms.rs", 28), + (root / "src" / "cfg_forms.rs", 30), + (root / "src" / "contest.rs", 1), + (root / "src" / "nested.rs", 14), + (root / "src" / "nested.rs", 20), + (root / "src" / "production.rs", 2), + (root / "src" / "production.rs", 3), + ] + if found != expected: + raise AssertionError(f"checker self-test mismatch: {found!r}") + + (root / "src" / "production.rs").write_text( + "pub fn seed() { sqlx::query!(\"SELECT 1\"); }\n", encoding="utf-8" + ) + (root / "src" / "contest.rs").write_text( + "pub fn production_contest() { sqlx::query!(\"contest\"); }\n", + encoding="utf-8", + ) + (root / "src" / "nested.rs").write_text( + "#[cfg(test)]\nmod tests { sqlx::query(\"masked\"); }\n", + encoding="utf-8", + ) + (root / "src" / "cfg_forms.rs").write_text( + "#[cfg(all(test, feature = \"fixtures\"))]\n" + "mod all_tests { sqlx::query(\"masked\"); }\n", + encoding="utf-8", + ) + found = violations(root) + if found: + raise AssertionError(f"allowed macro self-test mismatch: {found!r}") + print("check-production-sql self-test: PASS") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + parser.add_argument( + "root", + nargs="?", + type=Path, + default=Path(__file__).resolve().parent.parent, + help="repository root (defaults to the directory containing this script)", + ) + args = parser.parse_args() + if args.self_test: + run_self_test() + return 0 + + root = args.root.resolve() + findings = violations(root) + if findings: + for path, line in findings: + print(f"{path.relative_to(root)}:{line}: runtime sqlx query in production source") + return 1 + print("check-production-sql: PASS (no runtime SQLx queries in production src)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())