upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-04-10 16:25:26 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-04-10 16:30:02 +0000
commit47622eb762e802a9caa2f37d8162eaaf2f9aa9ca (patch)
tree84afdf6b91aca8248a7cebdcc72ab5f782e847d8
parent1978add2aa61a7e65655ec023d339e656402ad7b (diff)
fix: percent-encode identifier in nostr:// URLs and GRASP HTTP paths
Repository identifiers can contain any characters per NIP-01 d-tag rules. Encode them in nostr:// clone URLs (display and parse) and in GRASP /<npub>/<identifier>.git paths, aligning with NIP-34 and GRASP-01.
-rw-r--r--CHANGELOG.md1
-rw-r--r--src/lib/git/nostr_url.rs62
-rw-r--r--src/lib/repo_ref.rs8
3 files changed, 63 insertions, 8 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8594feb..bd3c4dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15 15
16- more robust patch parsing and gracefully handle errors (7a36aed, e1dd109, 6a2245d), 16- more robust patch parsing and gracefully handle errors (7a36aed, e1dd109, 6a2245d),
17- panic when cloning a bare `nostr://npub/identifier` URL with no relay hints (f3a6ae8) 17- panic when cloning a bare `nostr://npub/identifier` URL with no relay hints (f3a6ae8)
18- repository identifiers containing reserved characters (e.g. spaces, emoji) are now percent-encoded in `nostr://` clone URLs and GRASP HTTP paths, per [NIP-34](https://github.com/nostr-protocol/nips/pull/2312)
18 19
19## [2.3.0] - 2026-03-05 20## [2.3.0] - 2026-03-05
20 21
diff --git a/src/lib/git/nostr_url.rs b/src/lib/git/nostr_url.rs
index c26c56a..d6ee24e 100644
--- a/src/lib/git/nostr_url.rs
+++ b/src/lib/git/nostr_url.rs
@@ -95,7 +95,7 @@ impl fmt::Display for NostrUrlDecoded {
95 ) 95 )
96 )?; 96 )?;
97 } 97 }
98 write!(f, "{}", self.coordinate.identifier) 98 write!(f, "{}", urlencoding::encode(&self.coordinate.identifier))
99 } 99 }
100} 100}
101 101
@@ -178,10 +178,11 @@ impl NostrUrlDecoded {
178 } else { 178 } else {
179 let npub_or_nip05 = part.to_owned(); 179 let npub_or_nip05 = part.to_owned();
180 parts.remove(0); 180 parts.remove(0);
181 let identifier = parts 181 let identifier = urlencoding::decode(parts.pop().context(
182 .pop() 182 "nostr url must have an identifier eg. nostr://npub123/repo-identifier",
183 .context("nostr url must have an identifier eg. nostr://npub123/repo-identifier")? 183 )?)
184 .to_string(); 184 .context("could not percent-decode identifier in nostr git url")?
185 .into_owned();
185 for relay in parts { 186 for relay in parts {
186 let mut decoded = urlencoding::decode(relay) 187 let mut decoded = urlencoding::decode(relay)
187 .context("could not parse relays in nostr git url")? 188 .context("could not parse relays in nostr git url")?
@@ -1058,6 +1059,31 @@ mod tests {
1058 } 1059 }
1059 1060
1060 #[test] 1061 #[test]
1062 fn identifier_with_spaces_is_percent_encoded() -> Result<()> {
1063 assert_eq!(
1064 format!("{}", NostrUrlDecoded {
1065 original_string: String::new(),
1066 coordinate: Nip19Coordinate {
1067 coordinate: Coordinate {
1068 identifier: "my repo".to_string(),
1069 public_key: PublicKey::parse(
1070 "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr",
1071 )
1072 .unwrap(),
1073 kind: nostr_sdk::Kind::GitRepoAnnouncement,
1074 },
1075 relays: vec![],
1076 },
1077 protocol: None,
1078 ssh_key_file: None,
1079 nip05: None,
1080 }),
1081 "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my%20repo",
1082 );
1083 Ok(())
1084 }
1085
1086 #[test]
1061 fn with_protocol() -> Result<()> { 1087 fn with_protocol() -> Result<()> {
1062 assert_eq!( 1088 assert_eq!(
1063 format!("{}", NostrUrlDecoded { 1089 format!("{}", NostrUrlDecoded {
@@ -1353,6 +1379,32 @@ mod tests {
1353 } 1379 }
1354 1380
1355 #[tokio::test] 1381 #[tokio::test]
1382 async fn percent_encoded_identifier_is_decoded() -> Result<()> {
1383 let url = "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my%20repo".to_string();
1384 let decoded = NostrUrlDecoded::parse_and_resolve(&url, &None).await?;
1385 assert_eq!(decoded.coordinate.identifier, "my repo");
1386 Ok(())
1387 }
1388
1389 #[tokio::test]
1390 async fn percent_encoded_identifier_round_trips() -> Result<()> {
1391 // parse a URL with an encoded identifier, then re-display it and get the same
1392 // URL back
1393 let url = "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my%20repo".to_string();
1394 let decoded = NostrUrlDecoded::parse_and_resolve(&url, &None).await?;
1395 // Display re-encodes, but original_string is stored so we need a fresh struct
1396 let redisplayed = format!(
1397 "{}",
1398 NostrUrlDecoded {
1399 original_string: String::new(),
1400 ..decoded
1401 }
1402 );
1403 assert_eq!(redisplayed, url);
1404 Ok(())
1405 }
1406
1407 #[tokio::test]
1356 async fn with_server_protocol() -> Result<()> { 1408 async fn with_server_protocol() -> Result<()> {
1357 let url = "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit".to_string(); 1409 let url = "nostr://ssh/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit".to_string();
1358 assert_eq!( 1410 assert_eq!(
diff --git a/src/lib/repo_ref.rs b/src/lib/repo_ref.rs
index c0f9136..c4dd820 100644
--- a/src/lib/repo_ref.rs
+++ b/src/lib/repo_ref.rs
@@ -14,6 +14,7 @@ use nostr::{
14}; 14};
15use nostr_sdk::{Kind, NostrSigner, RelayUrl, Timestamp, Url}; 15use nostr_sdk::{Kind, NostrSigner, RelayUrl, Timestamp, Url};
16use serde::{Deserialize, Serialize}; 16use serde::{Deserialize, Serialize};
17use urlencoding::encode as pct_encode;
17 18
18#[cfg(not(test))] 19#[cfg(not(test))]
19use crate::client::Client; 20use crate::client::Client;
@@ -661,7 +662,7 @@ pub fn detect_existing_grasp_servers(
661 } 662 }
662 663
663 let clone_url_is_grasp_server_format = if let Ok(npub) = extract_npub(url) { 664 let clone_url_is_grasp_server_format = if let Ok(npub) = extract_npub(url) {
664 url.contains(&format!("/{npub}/{identifier}.git")) 665 url.contains(&format!("/{npub}/{}.git", pct_encode(identifier)))
665 } else { 666 } else {
666 false 667 false
667 }; 668 };
@@ -809,8 +810,9 @@ pub fn format_grasp_server_url_as_clone_url(
809 "https://" 810 "https://"
810 }; 811 };
811 Ok(format!( 812 Ok(format!(
812 "{prefix}{grasp_server_url}/{}/{identifier}.git", 813 "{prefix}{grasp_server_url}/{}/{}.git",
813 public_key.to_bech32()? 814 public_key.to_bech32()?,
815 pct_encode(identifier)
814 )) 816 ))
815} 817}
816 818