diff options
| -rw-r--r-- | CHANGELOG.md | 1 | ||||
| -rw-r--r-- | src/lib/git/nostr_url.rs | 62 | ||||
| -rw-r--r-- | src/lib/repo_ref.rs | 8 |
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 | }; |
| 15 | use nostr_sdk::{Kind, NostrSigner, RelayUrl, Timestamp, Url}; | 15 | use nostr_sdk::{Kind, NostrSigner, RelayUrl, Timestamp, Url}; |
| 16 | use serde::{Deserialize, Serialize}; | 16 | use serde::{Deserialize, Serialize}; |
| 17 | use urlencoding::encode as pct_encode; | ||
| 17 | 18 | ||
| 18 | #[cfg(not(test))] | 19 | #[cfg(not(test))] |
| 19 | use crate::client::Client; | 20 | use 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 | ||