@@ -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.
918918const 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) ]
921925mod 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