Rust in Everything
基于 sea-orm 2.0.4

contains("_") 匹配了每一行

主线位置:查询主线的第一段,filter 里的条件。Column::Name.contains(s) 在构造 Select 时就把 s 拼成了 LIKE 的模式字符串,之后它作为 $1 的值原样发出去。值是参数,不会造成 SQL 注入;但模式里的 %、_、\ 由 Postgres 的 LIKE 解释,不是按字面匹配。条件方法的全貌见列与值,值怎么变成 $N 见 QueryBuilder 与参数。

flowchart TB
  inp["用户输入 s"] --> fmt["format! 拼出模式"]
  fmt --> val["$1 = 模式字符串"]
  val --> like["Postgres LIKE"]
  like --> pct["% 任意串"]
  like --> und["_ 任意一个字符"]
  like --> bs["反斜杠 转义下一个字符"]
  esc["先转义, 再 LikeExpr escape"] --> lit["按字面匹配"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sea-orm = "=2.0.4",对照组 =1.1.20。表:

CREATE TABLE product (id serial PRIMARY KEY, name text NOT NULL);
INSERT INTO product (name) VALUES ('100% cotton'), ('100 pcs'), ('snake_case'), ('snakeXcase'), ('C:\temp');

最后一行的值里是一个字面的反斜杠。

现象

src/bin/like.rs 把几个搜索词原样交给 contains,像一个搜索框那样:

async fn names(db: &sea_orm::DatabaseConnection, cond: sea_orm::sea_query::Expr) -> Result<Vec<String>, DbErr> {
  Ok(Product::find().filter(cond).order_by_asc(Column::Id).all(db).await?.into_iter().map(|m| m.name).collect())
}

// ...
  println!("--- user input passed straight in");
  for q in ["_", "%", "100%", "snake_case", r"C:\temp"] {
    println!("contains({q:?}) -> {:?}", names(&db, Column::Name.contains(q)).await?);
  }
--- user input passed straight in
contains("_") -> ["100% cotton", "100 pcs", "snake_case", "snakeXcase", "C:\\temp"]
contains("%") -> ["100% cotton", "100 pcs", "snake_case", "snakeXcase", "C:\\temp"]
contains("100%") -> ["100% cotton", "100 pcs"]
contains("snake_case") -> ["snake_case", "snakeXcase"]
...
  • 搜 _ 和 % 返回整张表:模式是 %_%(至少一个字符)和 %%%(任意串)。
  • 搜 100% 多出了 100 pcs,搜 snake_case 多出了 snakeXcase。
  • 没有报错,只是结果多了。省略的那一行留给动脑筋。

出处

ColumnTrait::contains:

    fn contains<T>(&self, s: T) -> Expr
    where
        T: Into<String>,
    {
        let pattern = format!("%{}%", s.into());
        Expr::col(self.as_column_ref()).like(pattern)
    }

starts_with 和 ends_with 一样,只是 % 放在一边。三个方法都没有转义,文档注释里写的是「This is a simplified shorthand for a more general like method. Use like if you need something more complex, like specifying an escape character.」。

发出去的是什么(src/bin/like_sql.rs,sqlx 日志加 debug-print,debug-print 那一行是值内联后的样子):

DEBUG sea_orm::driver::sqlx_postgres: SELECT "product"."id", "product"."name" FROM "product" WHERE "product"."name" LIKE '%100%%'
 INFO sqlx::query: summary="SELECT \"product\".\"id\", \"product\".\"name\" FROM …" db.statement="\n\nSELECT \"product\".\"id\", \"product\".\"name\" FROM \"product\" WHERE \"product\".\"name\" LIKE $1\n" rows_affected=2 rows_returned=2 elapsed=3.502917ms elapsed_secs=0.003502917

模式 %100%% 作为 $1 发出,第二个 % 在 LIKE 里就是通配符。

📝NOTE
冷知识:ColumnTrait::like 到 1.1.9 才能带 ESCAPE

sea-query 从 0.26.0 起就有 LikeExpr::escape(sea-query#352,CHANGELOG 记为「LIKE ESCAPE support」),但 sea-orm 的 ColumnTrait::like / not_like 原来的签名是 T: Into<String>,只收一个字符串,用列方法写不出 ESCAPE。#2549(「Accept LikeExpr in like and not_like」,首次发布于 1.1.9)把参数改成 T: IntoLikeExpr,PR 说明是「Allow to specify an escape character in like and not_like」,「The old signatures were insufficiently generic and didn't accept LikeExpr」;因为 IntoLikeExpr 对所有 T: Into<String> 都有实现,这个改动不破坏旧代码。上面那句「Use like if you need something more complex, like specifying an escape character」也是这个 PR 加在 contains 等三个方法上的。contains 本身没有改,仍然是 Into<String> 加 format!。本站用的 1.1.20 已经包含这个改动。

修法

先把输入里的 \、%、_ 转义,再用 like 加 LikeExpr::escape 明确写出转义字符:

use sea_orm::sea_query::LikeExpr;

/// Escape LIKE metacharacters so user input matches literally (escape char: backslash).
fn escape_like(s: &str) -> String {
  let mut out = String::with_capacity(s.len());
  for c in s.chars() {
    if matches!(c, '\\' | '%' | '_') {
      out.push('\\');
    }
    out.push(c);
  }
  out
}

// ...
  for q in ["_", "%", "100%", "snake_case", r"C:\temp"] {
    let pattern = format!("%{}%", escape_like(q));
    let cond = Column::Name.like(LikeExpr::new(pattern).escape('\\'));
    println!("like({q:?}) -> {:?}", names(&db, cond).await?);
  }
--- escaped
like("_") -> ["snake_case"]
like("%") -> ["100% cotton"]
like("100%") -> ["100% cotton"]
like("snake_case") -> ["snake_case"]
like("C:\\temp") -> ["C:\\temp"]

每个搜索词都只匹配字面包含它的那一行。ilike / not_like / not_ilike 也收 IntoLikeExpr,用法相同。转义字符也可以换成别的(例如下面的 !),escape_like 里插入的前缀和要转义的字符要跟着换。

ESCAPE 子句发出去的样子(同一个 like_sql.rs):

DEBUG sea_orm::driver::sqlx_postgres: SELECT "product"."id", "product"."name" FROM "product" WHERE "product"."name" LIKE E'%100\\%%' ESCAPE E'\\'
 INFO sqlx::query: summary="SELECT \"product\".\"id\", \"product\".\"name\" FROM …" db.statement="\n\nSELECT \"product\".\"id\", \"product\".\"name\" FROM \"product\" WHERE \"product\".\"name\" LIKE $1 ESCAPE E'\\\\'\n" rows_affected=1 rows_returned=1 elapsed=1.466292ms elapsed_secs=0.001466292
DEBUG sea_orm::driver::sqlx_postgres: SELECT "product"."id", "product"."name" FROM "product" WHERE "product"."name" LIKE '%100!%%' ESCAPE '!'
 INFO sqlx::query: summary="SELECT \"product\".\"id\", \"product\".\"name\" FROM …" db.statement="\n\nSELECT \"product\".\"id\", \"product\".\"name\" FROM \"product\" WHERE \"product\".\"name\" LIKE $1 ESCAPE '!'\n" rows_affected=1 rows_returned=1 elapsed=646.625µs elapsed_secs=0.000646625

模式仍然是参数 $1,转义字符却直接写进了 SQL:From<LikeExpr> for Expr 把它做成 BinOper::Escape 右边的 Expr::Constant,常量不变成参数。反斜杠被写成 E'\\'(sqlx 日志的 db.statement 是 Debug 格式,又把每个反斜杠加倍了一次),即 Postgres 的转义字符串语法里的一个反斜杠;换成 ! 就是普通的 '!'。转义字符来自代码而不是用户,写进 SQL 没有问题。

怎么确认没中招

  • 在代码里找 .contains(、.starts_with(、.ends_with(、.like(、.ilike( 作用在 Column 上的地方,看参数是不是来自用户输入(搜索框、筛选条件、URL 参数)。是的话就要先转义。
  • 给搜索接口加一条测试:搜 _ 和 %,期望只返回字面包含这两个字符的记录。
  • 本站:按固定提交 89a61b1 在 crates/ 下没有对 sea-orm 列调用这几个方法,也没有手写的 LIKE 语句,不受影响。
❗IMPORTANT
动脑筋:上面省略的那一行是 contains(r"C:\temp")。表里确实有一行 C:\temp,它会返回哪些行?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。

1.1.20(exp-seaorm-pit1-old/src/bin/like_old.rs,同一个程序换成展开写法的实体)的十行输出和 2.0.4 逐行相同,包括 like(LikeExpr::new(..).escape('\\')) 那一半。

动脑筋答案

contains("C:\\temp") -> []

一行也没有,连 C:\temp 自己都查不到。模式是 %C:\temp%。Postgres 的 LIKE 不写 ESCAPE 时默认以反斜杠为转义字符,\t 的意思是「字面的 t」,于是模式等价于 %C:temp%,而表里的值中间有一个反斜杠,匹配不上。所以不转义的代价不只是「结果多了」:输入里带反斜杠时结果会变少。转义之后的 like("C:\\temp") 模式是 %C:\\temp%,\\ 是字面的反斜杠,查到了这一行。

小结

  • contains / starts_with / ends_with 是 format! 加 like,不转义 %、_、\;值是参数,没有注入问题,但会多匹配,遇到反斜杠还会少匹配。2.0.4 和 1.1.20 一样。
  • 修法:自己转义 \、%、_,再用 Column::X.like(LikeExpr::new(pattern).escape('\\'));ilike 等同理。
  • ESCAPE 的字符以常量写进 SQL(ESCAPE E'\\'),模式仍是 $1。
  • ColumnTrait::like 从 1.1.9(#2549)起收 LikeExpr,本站的 1.1.20 可以直接用。

💬 关联讨论

发起讨论
加载中...