Detect VACUUM in SQL analysis

Refs https://github.com/simonw/datasette/pull/2749#issuecomment-4559073803
This commit is contained in:
Simon Willison 2026-05-27 16:30:05 -07:00
commit 951f5a9f30
4 changed files with 108 additions and 1 deletions

View file

@ -720,6 +720,7 @@ def operation_is_write(operation: Operation) -> bool:
"pragma",
"analyze",
"reindex",
"vacuum",
"unknown",
}

View file

@ -22,6 +22,7 @@ SQLOperation = Literal[
"pragma",
"analyze",
"reindex",
"vacuum",
"unknown",
]
SQLTargetType = Literal[
@ -423,10 +424,40 @@ def analyze_sql_tables(
conn.set_authorizer(authorizer)
try:
conn.execute("EXPLAIN " + sql, params if params is not None else {}).fetchall()
explain_rows = conn.execute(
"EXPLAIN " + sql, params if params is not None else {}
).fetchall()
finally:
conn.set_authorizer(None)
if not operations:
vacuum_row = next((row for row in explain_rows if row[1] == "Vacuum"), None)
if vacuum_row is not None:
schema_by_index = {
row[0]: row[1] for row in conn.execute("PRAGMA database_list")
}
sqlite_schema = schema_by_index.get(vacuum_row[2])
database = database_for_schema(sqlite_schema)
record(
"vacuum",
"database",
database=database,
table=None,
sqlite_schema=sqlite_schema,
target=database,
source=None,
)
else:
record(
"unknown",
"statement",
database=database_name,
table=None,
sqlite_schema=None,
target=None,
source=None,
)
has_schema_operation = any(
key.target_type in {"table", "index", "view", "trigger", "virtual-table"}
and key.operation in {"create", "alter", "drop"}

View file

@ -2011,6 +2011,37 @@ async def test_execute_write_rejects_function_operations():
assert (await db.execute("select name from dogs")).dicts() == []
@pytest.mark.asyncio
async def test_execute_write_rejects_vacuum_operation():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"view-database": {"id": "writer"},
"execute-write-sql": {"id": "writer"},
}
}
}
},
)
ds.add_memory_database("execute_write_vacuum_operation", name="data")
await ds.invoke_startup()
denied_response = await ds.client.post(
"/data/-/execute-write",
actor={"id": "writer"},
json={"sql": "vacuum"},
)
assert denied_response.status_code == 403
assert denied_response.json()["errors"] == [
"Unsupported SQL operation: vacuum database"
]
@pytest.mark.asyncio
async def test_execute_write_create_table_uses_create_table_permission():
ds = Datasette(

View file

@ -129,6 +129,50 @@ def test_analyze_create_table_operation():
]
def test_analyze_vacuum_operation():
conn = sqlite3.connect(":memory:")
try:
analysis = analyze_sql_tables(conn, "vacuum", database_name="data")
finally:
conn.close()
assert [operation_dict(operation) for operation in analysis.operations] == [
{
"operation": "vacuum",
"target_type": "database",
"database": "data",
"sqlite_schema": "main",
"table": None,
"target": "data",
"columns": (),
"source": None,
"internal": False,
}
]
def test_analyze_statement_with_no_authorizer_callbacks_is_unknown():
conn = sqlite3.connect(":memory:")
try:
analysis = analyze_sql_tables(conn, "reindex", database_name="data")
finally:
conn.close()
assert [operation_dict(operation) for operation in analysis.operations] == [
{
"operation": "unknown",
"target_type": "statement",
"database": "data",
"sqlite_schema": None,
"table": None,
"target": None,
"columns": (),
"source": None,
"internal": False,
}
]
def test_analyze_transaction_operation(conn):
analysis = analyze_sql_tables(conn, "commit", database_name="data")