Skip to content

Commit acb4714

Browse files
authored
Merge pull request #298 from euxaristia/fix/get-profile-strict-did-matching
fix: strict DID matching in get_profile
2 parents c83cbc5 + b4016dc commit acb4714

4 files changed

Lines changed: 264 additions & 16 deletions

File tree

‎Cargo.lock‎

Lines changed: 81 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/gitlawb-node/src/db/mod.rs‎

Lines changed: 170 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,10 @@ pub(crate) fn normalize_owner_key(did: &str) -> &str {
917917
/// drift apart. If you change `normalize_owner_key`, update this const too.
918918
const OWNER_KEY_CASE_SQL: &str = "CASE WHEN owner_did LIKE 'did:key:%' AND position(':' in substr(owner_did, 9)) = 0 THEN substr(owner_did, 9) ELSE owner_did END";
919919

920+
/// SQL CASE expression byte-identical to `normalize_owner_key`, but for columns
921+
/// named `did` (like in agent_profiles) instead of `owner_did`.
922+
const PROFILE_DID_CASE_SQL: &str = "CASE WHEN did LIKE 'did:key:%' AND position(':' in substr(did, 9)) = 0 THEN substr(did, 9) ELSE did END";
923+
920924
#[cfg(test)]
921925
mod normalize_owner_key_tests {
922926
use super::normalize_owner_key;
@@ -3454,6 +3458,9 @@ impl Db {
34543458
let new_website = website.or(existing.website.as_deref());
34553459
let new_socials = socials.or(existing.socials.as_deref());
34563460

3461+
// get_profile equates bare short ids with did:key:<id>. UPDATE must target
3462+
// the stored row identity (existing.did), not the caller's raw input form,
3463+
// or a did:key: alias against a bare-stored profile updates zero rows.
34573464
sqlx::query(
34583465
"UPDATE agent_profiles
34593466
SET display_name=$1, bio=$2, avatar_url=$3, website=$4, socials=$5, updated_at=$6
@@ -3465,12 +3472,12 @@ impl Db {
34653472
.bind(new_website)
34663473
.bind(new_socials)
34673474
.bind(&now)
3468-
.bind(did)
3475+
.bind(&existing.did)
34693476
.execute(&self.pool)
34703477
.await?;
34713478

34723479
Ok(ProfileRecord {
3473-
did: did.to_string(),
3480+
did: existing.did,
34743481
display_name: new_name.map(String::from),
34753482
bio: new_bio.map(String::from),
34763483
avatar_url: new_avatar.map(String::from),
@@ -3511,14 +3518,20 @@ impl Db {
35113518
}
35123519

35133520
pub async fn get_profile(&self, did: &str) -> Result<Option<ProfileRecord>> {
3514-
let row = sqlx::query(
3521+
// Same owner-key contract as get_repo: strip `did:key:` only when the
3522+
// remainder is a bare key id. The old `LIKE '%:' || $1` matched any DID
3523+
// method that shared a suffix and could resolve the wrong profile.
3524+
let did_key = normalize_owner_key(did);
3525+
let sql = format!(
35153526
"SELECT did, display_name, bio, avatar_url, website, socials, profile_cid, created_at, updated_at
35163527
FROM agent_profiles
3517-
WHERE did = $1 OR did LIKE '%:' || $1",
3518-
)
3519-
.bind(did)
3520-
.fetch_optional(&self.pool)
3521-
.await?;
3528+
WHERE ({key}) = $1",
3529+
key = PROFILE_DID_CASE_SQL
3530+
);
3531+
let row = sqlx::query(&sql)
3532+
.bind(did_key)
3533+
.fetch_optional(&self.pool)
3534+
.await?;
35223535

35233536
Ok(row.map(|r| ProfileRecord {
35243537
did: r.get("did"),
@@ -3534,10 +3547,17 @@ impl Db {
35343547
}
35353548

35363549
pub async fn set_profile_cid(&self, did: &str, cid: &str) -> Result<()> {
3537-
sqlx::query("UPDATE agent_profiles SET profile_cid = $1, updated_at = $2 WHERE did = $3")
3550+
// Same did:key / bare equivalence as get_profile so a full did:key: form
3551+
// updates a profile stored under the bare short id.
3552+
let did_key = normalize_owner_key(did);
3553+
let sql = format!(
3554+
"UPDATE agent_profiles SET profile_cid = $1, updated_at = $2 WHERE ({key}) = $3",
3555+
key = PROFILE_DID_CASE_SQL
3556+
);
3557+
sqlx::query(&sql)
35383558
.bind(cid)
35393559
.bind(Utc::now().to_rfc3339())
3540-
.bind(did)
3560+
.bind(did_key)
35413561
.execute(&self.pool)
35423562
.await?;
35433563
Ok(())
@@ -4671,6 +4691,103 @@ mod dedup_db_tests {
46714691
assert!(!got.is_public, "non-key row's is_public must be preserved");
46724692
}
46734693

4694+
/// get_profile must not resolve a non-key DID (e.g. did:gitlawb:) when
4695+
/// queried with the bare short id. The old `LIKE '%:' || $1` clause was too
4696+
/// broad and could return the wrong profile row.
4697+
#[sqlx::test]
4698+
async fn get_profile_does_not_match_non_key_did(pool: PgPool) {
4699+
let db = db(pool).await;
4700+
let short = "z6Mkprof1";
4701+
4702+
// Seed only a non-key DID row first. When queried with the bare short ID,
4703+
// get_profile must return None (lone non-key fixture test).
4704+
db.upsert_profile(
4705+
&format!("did:gitlawb:{short}"),
4706+
Some("other-method"),
4707+
None,
4708+
None,
4709+
None,
4710+
None,
4711+
)
4712+
.await
4713+
.unwrap();
4714+
4715+
let got = db.get_profile(short).await.unwrap();
4716+
assert!(
4717+
got.is_none(),
4718+
"bare short id must not resolve a lone non-key DID profile"
4719+
);
4720+
4721+
// Now seed the canonical bare key profile row as well.
4722+
db.upsert_profile(short, Some("canonical"), None, None, None, None)
4723+
.await
4724+
.unwrap();
4725+
4726+
let got = db
4727+
.get_profile(short)
4728+
.await
4729+
.unwrap()
4730+
.expect("bare short id should resolve the key-form profile");
4731+
assert_eq!(got.did, short);
4732+
assert_eq!(got.display_name.as_deref(), Some("canonical"));
4733+
4734+
let got = db
4735+
.get_profile(&format!("did:key:{short}"))
4736+
.await
4737+
.unwrap()
4738+
.expect("did:key form should also resolve the key-form profile");
4739+
assert_eq!(got.did, short);
4740+
4741+
let got = db
4742+
.get_profile(&format!("did:gitlawb:{short}"))
4743+
.await
4744+
.unwrap()
4745+
.expect("full non-key DID should resolve its own profile");
4746+
assert_eq!(got.did, format!("did:gitlawb:{short}"));
4747+
assert_eq!(got.display_name.as_deref(), Some("other-method"));
4748+
}
4749+
4750+
/// upsert_profile must update a bare-stored profile when called with the
4751+
/// full did:key: form. get_profile equates the two, so the UPDATE has to
4752+
/// target existing.did rather than the raw input.
4753+
#[sqlx::test]
4754+
async fn upsert_profile_updates_via_did_key_alias(pool: PgPool) {
4755+
let db = db(pool).await;
4756+
let short = "z6Mkprof2";
4757+
4758+
db.upsert_profile(short, Some("before"), None, None, None, None)
4759+
.await
4760+
.unwrap();
4761+
4762+
let updated = db
4763+
.upsert_profile(
4764+
&format!("did:key:{short}"),
4765+
Some("after"),
4766+
None,
4767+
None,
4768+
None,
4769+
None,
4770+
)
4771+
.await
4772+
.unwrap();
4773+
assert_eq!(updated.did, short, "preserve the stored did form on update");
4774+
assert_eq!(updated.display_name.as_deref(), Some("after"));
4775+
4776+
let got = db
4777+
.get_profile(short)
4778+
.await
4779+
.unwrap()
4780+
.expect("profile should still resolve by bare short id");
4781+
assert_eq!(got.did, short);
4782+
assert_eq!(got.display_name.as_deref(), Some("after"));
4783+
4784+
db.set_profile_cid(&format!("did:key:{short}"), "bafytestcid")
4785+
.await
4786+
.unwrap();
4787+
let got = db.get_profile(short).await.unwrap().unwrap();
4788+
assert_eq!(got.profile_cid.as_deref(), Some("bafytestcid"));
4789+
}
4790+
46744791
/// Verify that the Rust `normalize_owner_key` and the `OWNER_KEY_CASE_SQL`
46754792
/// expression agree on every boundary value in the owner-key normalization
46764793
/// set. A mismatch would let the Rust code bind a different key than the SQL
@@ -4720,6 +4837,49 @@ mod dedup_db_tests {
47204837
);
47214838
}
47224839
}
4840+
4841+
/// Verify that `PROFILE_DID_CASE_SQL` (which aliases the column `did`) also
4842+
/// agrees with Rust `normalize_owner_key` across the full boundary matrix.
4843+
#[sqlx::test]
4844+
async fn profile_did_case_sql_matches_normalize_owner_key(pool: PgPool) {
4845+
let boundary_values = [
4846+
"did:key:z6Mkfoo",
4847+
"z6Mkfoo",
4848+
"did:gitlawb:z6Mkfoo",
4849+
"did:web:example.com:alice",
4850+
"did:key:did:gitlawb:z6Mkfoo",
4851+
"",
4852+
"did:key:",
4853+
"DID:KEY:z6Mkfoo",
4854+
];
4855+
4856+
let values_sql: String = boundary_values
4857+
.iter()
4858+
.map(|v| format!("('{}'::text)", v))
4859+
.collect::<Vec<_>>()
4860+
.join(", ");
4861+
let sql = format!(
4862+
"WITH data(did) AS (VALUES {values_sql})
4863+
SELECT did, ({key}) AS normalized FROM data ORDER BY did",
4864+
key = super::PROFILE_DID_CASE_SQL
4865+
);
4866+
4867+
let rows: Vec<(String, String)> = sqlx::query_as(&sql).fetch_all(&pool).await.unwrap();
4868+
4869+
assert_eq!(
4870+
rows.len(),
4871+
boundary_values.len(),
4872+
"every boundary value must produce a row"
4873+
);
4874+
4875+
for (val, sql_result) in &rows {
4876+
let rust_result = super::normalize_owner_key(val);
4877+
assert_eq!(
4878+
sql_result, rust_result,
4879+
"PROFILE_DID_CASE_SQL(\"{val}\") mismatch: Rust = \"{rust_result}\", SQL CASE = \"{sql_result}\""
4880+
);
4881+
}
4882+
}
47234883
}
47244884

47254885
/// Exercises the iCaptcha single-use proof ledger (`icaptcha_consumed_proofs`),

0 commit comments

Comments
 (0)