···4545 sqlx_query_scalar(AssertSqlSafe(sql.to_owned()))
4646}
47474848+/// Escape the `LIKE` wildcard metacharacters (`%`, `_`) and the escape character
4949+/// (`\`) in a value that will be embedded as a *literal* inside a `LIKE` pattern.
5050+/// Must be paired with an explicit `ESCAPE '\'` clause — SQLite has no default
5151+/// `LIKE` escape character, so without it the backslashes would be literal.
5252+pub fn escape_like(value: &str) -> String {
5353+ value
5454+ .replace('\\', "\\\\")
5555+ .replace('%', "\\%")
5656+ .replace('_', "\\_")
5757+}
5858+4859/// Database backend type, auto-detected from DATABASE_URL or set via DATABASE_BACKEND.
4960#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
5061#[serde(rename_all = "lowercase")]
···396407mod tests {
397408 use super::*;
398409 use chrono::Datelike;
410410+411411+ #[test]
412412+ fn escape_like_escapes_metacharacters() {
413413+ assert_eq!(escape_like("bafyrealcid"), "bafyrealcid"); // unchanged
414414+ assert_eq!(escape_like("%"), "\\%");
415415+ assert_eq!(escape_like("a_b"), "a\\_b");
416416+ assert_eq!(escape_like("a%b_c"), "a\\%b\\_c");
417417+ // Backslash is escaped first so it can't form a spurious escape sequence.
418418+ assert_eq!(escape_like("a\\%"), "a\\\\\\%");
419419+ }
399420400421 // -----------------------------------------------------------------------
401422 // DatabaseBackend detection
···981981 space_id: &str,
982982 blob_cid: &str,
983983) -> Result<Option<String>, AppError> {
984984- let pattern = format!("%\"$link\":\"{blob_cid}\"%");
984984+ // Escape LIKE metacharacters in the caller-supplied CID so `%`/`_` are matched
985985+ // literally and can't be used to match another author's record (L8).
986986+ let pattern = format!("%\"$link\":\"{}\"%", crate::db::escape_like(blob_cid));
985987 let sql = adapt_sql(
986986- "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? LIMIT 1",
988988+ "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? ESCAPE '\\' LIMIT 1",
987989 backend,
988990 );
989991 let row: Option<(String,)> = crate::db::query_as(&sql)
···745745 assert_eq!(fetched.record["v"], 2);
746746 assert_eq!(fetched.cid, "cid-v2");
747747}
748748+749749+// ---------------------------------------------------------------------------
750750+// find_blob_author_did — LIKE wildcard safety (L8)
751751+// ---------------------------------------------------------------------------
752752+753753+#[tokio::test]
754754+#[serial]
755755+async fn find_blob_author_did_treats_cid_wildcards_literally() {
756756+ common::require_db!();
757757+ let pool = test_db::test_pool().await;
758758+ let backend = test_db::test_backend();
759759+ test_db::truncate_all(&pool).await;
760760+761761+ let space_id = new_id();
762762+ let did = "did:plc:blob-space-owner";
763763+ let space = make_space(&space_id, did, "com.example.test", "blobspace");
764764+ spaces_db::create_space(&pool, backend, &space)
765765+ .await
766766+ .expect("create_space failed");
767767+768768+ let author = "did:plc:blob-author";
769769+ let blob_cid = "bafyrealcid";
770770+ let record = SpaceRecord {
771771+ uri: format!("at://{did}/space/com.example.test/blobspace/{author}/com.example.post/rk1"),
772772+ space_id: space_id.clone(),
773773+ author_did: author.to_string(),
774774+ collection: "com.example.post".to_string(),
775775+ rkey: "rk1".to_string(),
776776+ record: serde_json::json!({ "image": { "$link": blob_cid } }),
777777+ cid: "bafyrecordcid".to_string(),
778778+ indexed_at: now_rfc3339(),
779779+ };
780780+ spaces_db::insert_space_record(&pool, backend, &record)
781781+ .await
782782+ .expect("insert_space_record failed");
783783+784784+ // A genuine CID still resolves to the author (escaping must not break lookups).
785785+ let found = spaces_db::find_blob_author_did(&pool, backend, &space_id, blob_cid)
786786+ .await
787787+ .unwrap();
788788+ assert_eq!(found.as_deref(), Some(author));
789789+790790+ // `%` must be matched literally, not as a wildcard that leaks any blob ref.
791791+ let wildcard = spaces_db::find_blob_author_did(&pool, backend, &space_id, "%")
792792+ .await
793793+ .unwrap();
794794+ assert_eq!(wildcard, None, "'%' leaked a record via a LIKE wildcard");
795795+796796+ // `_` must not match the single differing character of a real CID.
797797+ let underscore = spaces_db::find_blob_author_did(&pool, backend, &space_id, "bafyreal_id")
798798+ .await
799799+ .unwrap();
800800+ assert_eq!(underscore, None, "'_' leaked a record via a LIKE wildcard");
801801+}