Skip to content

[Bug]: curate dry-run / output=None executes tenant SQL without reject_non_single_select #563

Description

@VARUN3WARE

Version or commit

5b720ec

Environment

Linux x86_64, Python via uv; library + CLI repro

Minimal reproduction

On main @ 5b720ec, the single-SELECT gate only runs when writing a manifest.

  1. Manifest path is gated — _stage_manifest_and_count calls reject_non_single_select(sql) before any DuckDB execute (src/hflow/curation.py ~705–709). The pinned surface in tests/test_catalog_curation.py (_SQL_SURFACE_PAYLOADS) covers multi-statement, DDL-only, PIVOT, PRAGMA/DESCRIBE/SHOW/SUMMARIZE, and the copy-escape shape.

  2. Dry-run / report-only path is not gated:

# src/hflow/curation.py ~899–902
case None:
    (row_count,) = connection.execute(f"SELECT count(*) FROM ({sql})").fetchone() or (
        0,
    )

No reject_non_single_select before that execute.

  1. CLI dry-run uses that path and never calls the gate itself:
# src/hflow/cli.py ~1489–1492
report = curate(
    arguments.catalog,
    sql,
    output=None if arguments.dry_run else arguments.output,
)
  1. Contrast: the hosted server always rejects first, then calls curate(..., output=None) (packages/hflow-server/src/hflow_server/_curation.py ~367–391). Library/CLI dry-run skip that boundary.

  2. Same injection SQL that manifest-write refuses (SELECT 1; CREATE TABLE pwned AS SELECT 1 AS x, or the _SQL_SURFACE_PAYLOADS copy-escape row that closes the wrapper paren early) is executed on the dry-run branch because the gate never runs. Dry-run wraps SQL as SELECT count(*) FROM ({sql}), so copy-escape shapes that close that paren are especially relevant.

Not covered by the current open board: #558, #545, #528, #498, #491, #481 (open PR #542 is the redundant-parse cleanup, not this missing gate), #376, #287, #246. Open PRs #562 / #542 / #517 / #380 / #142 do not close this seam.

Expected behavior

Every curate() path that executes tenant SQL — including output=None / hflow curate --dry-run — should run reject_non_single_select (or share the same helper) before connection.execute / connection.sql, matching the manifest-write path and the server boundary.

Refused shapes from _SQL_SURFACE_PAYLOADS must raise NonSingleSelectQueryError / ValueError on dry-run the same way they do on manifest write, with no side-effect tables/files.

Actual behavior

reject_non_single_select is only invoked from _stage_manifest_and_count. Report-only curation skips it. HOSTING.md / CATALOG.md tell operators that curate(..., constrained=True) is the tenant SQL posture; that locks file access but does not apply the statement-count gate on the dry-run branch.

Additional context

Definition of done

  • Call reject_non_single_select(sql) before the case None: count query in curate() (preferred: one place so every destination path stays consistent).
  • Extend the SQL-surface tests (or a sibling) so curate(..., output=None) / CLI --dry-run refuse the same _SQL_SURFACE_PAYLOADS injection rows as _stage_manifest_and_count.
  • Optional docs: note in HOSTING/CATALOG that the statement gate applies to report-only curate too, not only manifest materialization.

Non-goals

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workinghelp wantedExtra attention is needed

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions