use datafusion::common::tree_node::TreeNodeRecursion;
use datafusion::error::{DataFusionError, Result as DfResult};
use datafusion::logical_expr::LogicalPlan;
use datafusion::prelude::{DataFrame, SQLOptions, SessionContext};
fn deny_mutations() -> SQLOptions {
SQLOptions::new()
.with_allow_ddl(false)
.with_allow_dml(false)
.with_allow_statements(false)
}
fn wrapped_plan(plan: &LogicalPlan) -> Option<&LogicalPlan> {
match plan {
LogicalPlan::Explain(explain) => Some(&explain.plan),
LogicalPlan::Analyze(analyze) => Some(&analyze.input),
_ => None,
}
}
fn reject_nested_introspection(plan: &LogicalPlan) -> DfResult<()> {
let mut offender = None;
plan.apply_with_subqueries(|node| {
if std::ptr::eq(node, plan) {
return Ok(TreeNodeRecursion::Continue);
}
let name = match node {
LogicalPlan::Explain(_) => Some("EXPLAIN"),
LogicalPlan::Analyze(_) => Some("ANALYZE"),
_ => None,
};
if let Some(name) = name {
offender = Some(name);
Ok(TreeNodeRecursion::Stop)
} else {
Ok(TreeNodeRecursion::Continue)
}
})?;
match offender {
Some(name) => Err(DataFusionError::Plan(format!(
"a nested {name} is not permitted on a read-only SQL endpoint"
))),
None => Ok(()),
}
}
pub fn verify_read_only(plan: &LogicalPlan) -> DfResult<()> {
if matches!(plan, LogicalPlan::DescribeTable(_)) {
return Ok(());
}
if let Some(inner) = wrapped_plan(plan) {
deny_mutations().verify_plan(inner)?;
return reject_nested_introspection(inner);
}
deny_mutations().verify_plan(plan)?;
reject_nested_introspection(plan)
}
pub async fn plan_read_only(ctx: &SessionContext, sql: &str) -> DfResult<LogicalPlan> {
let plan = ctx.state().create_logical_plan(sql).await?;
verify_read_only(&plan)?;
Ok(plan)
}
pub async fn sql_read_only(ctx: &SessionContext, sql: &str) -> DfResult<DataFrame> {
let plan = plan_read_only(ctx, sql).await?;
ctx.execute_logical_plan(plan).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Chronix;
use crate::sql::create_session_context;
use chronix_core::ChronixConfig;
use std::sync::Arc;
fn test_ctx(dir: &tempfile::TempDir) -> SessionContext {
let config = ChronixConfig::builder()
.data_dir(dir.path())
.build()
.expect("config");
let db = Arc::new(Chronix::open(config).expect("open"));
create_session_context(db)
}
#[tokio::test]
async fn set_variable_is_rejected_without_taking_effect() {
let dir = tempfile::tempdir().expect("tmp");
let ctx = test_ctx(&dir);
let before = ctx.copied_config().options().execution.target_partitions;
let wanted = before + 1;
let err = plan_read_only(
&ctx,
&format!("SET datafusion.execution.target_partitions = {wanted}"),
)
.await
.expect_err("SET must be rejected");
assert!(err.to_string().contains("Statement not supported"), "{err}");
assert_eq!(
ctx.copied_config().options().execution.target_partitions,
before,
"SET leaked through and mutated the shared session"
);
}
#[tokio::test]
async fn external_table_is_rejected_before_touching_the_filesystem() {
let dir = tempfile::tempdir().expect("tmp");
let ctx = test_ctx(&dir);
let secret = dir.path().join("secret.csv");
std::fs::write(&secret, "user,token\nadmin,s3cr3t\n").expect("write");
let err = plan_read_only(
&ctx,
&format!(
"CREATE EXTERNAL TABLE leak STORED AS CSV LOCATION '{}'",
secret.display()
),
)
.await
.expect_err("CREATE EXTERNAL TABLE must be rejected");
assert!(err.to_string().contains("DDL not supported"), "{err}");
}
#[tokio::test]
async fn blocks_ddl_dml_and_statements() {
let dir = tempfile::tempdir().expect("tmp");
let ctx = test_ctx(&dir);
for sql in [
"CREATE VIEW v AS SELECT 1",
"CREATE TABLE t (x INT)",
"CREATE SCHEMA s",
"PREPARE p AS SELECT 1",
] {
assert!(
plan_read_only(&ctx, sql).await.is_err(),
"expected rejection for: {sql}"
);
}
}
#[tokio::test]
async fn permits_explaining_a_read() {
let dir = tempfile::tempdir().expect("tmp");
let ctx = test_ctx(&dir);
for sql in ["EXPLAIN SELECT 1", "EXPLAIN ANALYZE SELECT 1"] {
assert!(
plan_read_only(&ctx, sql).await.is_ok(),
"expected acceptance for: {sql}"
);
}
}
#[tokio::test]
async fn explain_does_not_wrap_a_mutation_past_the_check() {
let dir = tempfile::tempdir().expect("tmp");
let ctx = test_ctx(&dir);
for sql in [
"EXPLAIN CREATE VIEW v AS SELECT 1",
"EXPLAIN SET datafusion.catalog.information_schema = true",
"EXPLAIN ANALYZE SET datafusion.catalog.information_schema = true",
] {
assert!(
plan_read_only(&ctx, sql).await.is_err(),
"wrapping in EXPLAIN must not permit: {sql}"
);
}
}
#[tokio::test]
async fn blocks_mutations_nested_in_subqueries() {
let dir = tempfile::tempdir().expect("tmp");
let ctx = test_ctx(&dir);
if let Ok(plan) = ctx
.state()
.create_logical_plan("SELECT (EXPLAIN SELECT 1)")
.await
{
assert!(verify_read_only(&plan).is_err());
}
}
#[tokio::test]
async fn allows_plain_selects() {
let dir = tempfile::tempdir().expect("tmp");
let ctx = test_ctx(&dir);
let plan = plan_read_only(&ctx, "SELECT 1 AS x").await.expect("plan");
assert!(ctx.execute_logical_plan(plan).await.is_ok());
assert!(sql_read_only(&ctx, "SELECT 1 AS x").await.is_ok());
}
}