diff --git a/crates/gitlawb-node/src/api/mod.rs b/crates/gitlawb-node/src/api/mod.rs index c2988fa..10d76da 100644 --- a/crates/gitlawb-node/src/api/mod.rs +++ b/crates/gitlawb-node/src/api/mod.rs @@ -114,6 +114,12 @@ mod did_tests { assert!(!did_matches("did:key:zABC", "did:key:zXYZ")); assert!(!did_matches("zABC", "zXYZ")); } + + #[test] + fn empty_did_matches() { + assert!(did_matches("", "")); + assert!(did_matches("", "did:key:")); + } } /// Drift guard (plan 002 §Gate-type table, Step 5). Every in-scope mutation diff --git a/crates/gitlawb-node/src/api/profiles.rs b/crates/gitlawb-node/src/api/profiles.rs index 6f3a12a..4e42c6e 100644 --- a/crates/gitlawb-node/src/api/profiles.rs +++ b/crates/gitlawb-node/src/api/profiles.rs @@ -98,7 +98,7 @@ pub async fn set_profile( &state.http_client, &state.config.pinata_upload_url, &state.config.pinata_jwt, - &format!("profile-{}", &did), + &format!("profile-{}", did), &profile_json, ) .await diff --git a/crates/gitlawb-node/src/visibility.rs b/crates/gitlawb-node/src/visibility.rs index 657bb2f..5661687 100644 --- a/crates/gitlawb-node/src/visibility.rs +++ b/crates/gitlawb-node/src/visibility.rs @@ -25,16 +25,12 @@ fn nfc(s: &str) -> String { s.nfc().collect() } -/// True if `caller` is the repo owner. Matches exactly or against the -/// did:key short form — a non-key DID (did:gitlawb:/did:web:) in `owner_did` -/// never matches a bare short segment, and a non-key DID in `caller` never -/// matches a did:key owner's short form. Uses `normalize_owner_key` (not -/// `did_matches`) so the did:key-only rule is stricter: cross-method matching -/// would let a non-key canonical row bypass the #124 visibility gate. +/// True if `caller` is the repo owner. Uses [`crate::api::did_matches`] so the +/// owner check is representation-agnostic within `did:key` (full `did:key:z...` +/// matches bare `z...` stored by mirror rows) while still denying cross-method +/// collisions (`did:gitlawb:z...` vs bare `z...`). fn is_owner(owner_did: &str, caller: &str) -> bool { - let owner_short = crate::db::normalize_owner_key(owner_did); - let caller_short = crate::db::normalize_owner_key(caller); - owner_short == caller_short + crate::api::did_matches(owner_did, caller) } /// The match prefix for a glob: "/" stays "/", "/secret/**" becomes "/secret". @@ -429,6 +425,35 @@ mod tests { ); } + // #153 forward guard: is_owner must match across bare-key / full-did:key + // representations should someone revert to single-side normalize_owner_key. + #[test] + fn bare_owner_full_caller_allows_owner() { + let rules = [rule("/", VisibilityMode::A, &[])]; + assert_eq!( + visibility_check(&rules, false, "z6MkOwner", Some("did:key:z6MkOwner"), "/"), + Decision::Allow, + "bare owner + full did:key caller must match (guard against single-side revert)" + ); + } + + // #153 regression: cross-method DID must still be denied even when the + // trailing segment collides with a bare owner key. + #[test] + fn cross_method_did_denied_with_bare_owner() { + let rules = [rule("/", VisibilityMode::A, &[])]; + assert_eq!( + visibility_check(&rules, false, "z6MkFoo", Some("did:gitlawb:z6MkFoo"), "/"), + Decision::Deny, + "cross-method DID must not match a bare did:key owner" + ); + assert_eq!( + visibility_check(&rules, false, "z6MkFoo", Some("did:web:z6MkFoo"), "/"), + Decision::Deny, + "did:web must not match a bare did:key owner" + ); + } + #[test] fn root_rule_allows_listed_reader() { let rules = [rule("/", VisibilityMode::A, &["did:key:z6MkFriend"])];