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:42:35 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-04-10 16:42:35 +0000
commitdfd20a39a7ddaea07103cac45d4d79bc7e6ce0d7 (patch)
treef4d3c38c09c7b27a25f6b6933c9de0e42149c82f
parent2d74b9ca69b3a1e0b9a2359c12cc2d1979fc6130 (diff)
fix: accept any d-tag identifier; percent-encode in URLs
NIP-01 places no restriction on d tag characters and NIP-34 only recommends kebab-case without mandating it. Rejecting identifiers with whitespace or other URL-unsafe characters was therefore overly strict. The correct approach (per NIP-34 PR #2312 and GRASP-01) is to store identifiers verbatim on disk and percent-encode them when constructing URLs. The previous commit already handled the incoming direction (percent-decoding URL paths before filesystem lookup); this commit handles the outgoing direction and removes the validation restriction. Changes: - validate_identifier: drop whitespace rejection; only reject chars that are unsafe as filesystem directory names (/, \, null, . / ..) - git/mod.rs: add percent_encode() alongside percent_decode() - landing.rs: percent-encode identifier in nostr:// clone URL and gitworkshop link (also fixes a pre-existing bug where the clone URL displayed literal '{npub}' / '{identifier}' instead of the values)
-rw-r--r--CHANGELOG.md4
-rw-r--r--src/git/mod.rs51
-rw-r--r--src/http/landing.rs6
-rw-r--r--src/nostr/events.rs59
4 files changed, 81 insertions, 39 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 217781c..633ddbf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7 7
8## [Unreleased] 8## [Unreleased]
9 9
10### Fixed
11
12- Repository identifiers containing characters that require percent-encoding in URLs (e.g. spaces, emoji) are now accepted and served correctly. NIP-01 places no restriction on `d` tag values and NIP-34 only recommends kebab-case without mandating it, so rejecting non-kebab identifiers was overly strict. Identifiers are stored verbatim on disk and percent-encoded when used in URLs, per the `nostr://` clone URL spec formalised in [NIP-34 PR #2312](https://github.com/nostr-protocol/nips/pull/2312) and the GRASP-01 HTTP path spec. The landing page clone URL now also correctly percent-encodes the identifier.
13
10### Changed 14### Changed
11 15
12- Remove arbitrary default max connections limit; when `NGIT_MAX_CONNECTIONS` is unset the relay imposes no connection cap, deferring to OS fd limits and infrastructure controls 16- Remove arbitrary default max connections limit; when `NGIT_MAX_CONNECTIONS` is unset the relay imposes no connection cap, deferring to OS fd limits and infrastructure controls
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 999d3c8..156f125 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -451,6 +451,29 @@ pub fn get_repository_head(repo_path: &Path) -> Option<String> {
451 } 451 }
452} 452}
453 453
454/// Percent-encode a string for use as a URL path segment (RFC 3986 §2.1).
455///
456/// Encodes all bytes that are not unreserved characters (`A-Z a-z 0-9 - _ . ~`).
457/// This is suitable for encoding a repository identifier in a `nostr://` URL or
458/// an HTTP path component such as `/<npub>/<encoded-identifier>.git`.
459pub fn percent_encode(s: &str) -> String {
460 let mut out = String::with_capacity(s.len());
461 for byte in s.bytes() {
462 match byte {
463 // RFC 3986 unreserved characters — never encoded
464 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
465 out.push(byte as char);
466 }
467 _ => {
468 out.push('%');
469 out.push(char::from_digit((byte >> 4) as u32, 16).unwrap().to_ascii_uppercase());
470 out.push(char::from_digit((byte & 0xf) as u32, 16).unwrap().to_ascii_uppercase());
471 }
472 }
473 }
474 out
475}
476
454/// Decode percent-encoded characters in a URL path component. 477/// Decode percent-encoded characters in a URL path component.
455/// 478///
456/// Handles `%XX` sequences (e.g. `%20` → space). Invalid sequences are left as-is. 479/// Handles `%XX` sequences (e.g. `%20` → space). Invalid sequences are left as-is.
@@ -481,8 +504,8 @@ pub fn percent_decode(s: &str) -> String {
481/// 504///
482/// The identifier component is percent-decoded so that URLs like 505/// The identifier component is percent-decoded so that URLs like
483/// `/npub1.../my%20repo.git/info/refs` resolve to the filesystem path 506/// `/npub1.../my%20repo.git/info/refs` resolve to the filesystem path
484/// `my repo.git` (though such identifiers should be rejected at announcement 507/// `my repo.git`. Per NIP-34 and GRASP-01, identifiers MUST be percent-encoded
485/// validation time see `validate_announcement`). 508/// in URLs; they are stored verbatim on disk.
486/// 509///
487/// Returns (npub, identifier, subpath) where subpath is the part after .git/ 510/// Returns (npub, identifier, subpath) where subpath is the part after .git/
488/// and identifier has been percent-decoded. 511/// and identifier has been percent-decoded.
@@ -672,6 +695,30 @@ mod tests {
672 } 695 }
673 696
674 #[test] 697 #[test]
698 fn test_percent_encode_basic() {
699 assert_eq!(percent_encode("my-repo"), "my-repo");
700 assert_eq!(percent_encode("my_repo"), "my_repo");
701 assert_eq!(percent_encode("repo123"), "repo123");
702 assert_eq!(percent_encode("hello world"), "hello%20world");
703 assert_eq!(percent_encode("kuboslopp by Shakespeare"), "kuboslopp%20by%20Shakespeare");
704 }
705
706 #[test]
707 fn test_percent_encode_special_chars() {
708 assert_eq!(percent_encode("a/b"), "a%2Fb");
709 assert_eq!(percent_encode("a\\b"), "a%5Cb");
710 assert_eq!(percent_encode("a b\tc"), "a%20b%09c");
711 }
712
713 #[test]
714 fn test_percent_encode_decode_roundtrip() {
715 let identifiers = ["my-repo", "my repo", "kuboslopp by Shakespeare", "a/b", "foo\0bar"];
716 for id in &identifiers {
717 assert_eq!(percent_decode(&percent_encode(id)), *id);
718 }
719 }
720
721 #[test]
675 fn test_commit_exists_nonexistent() { 722 fn test_commit_exists_nonexistent() {
676 let (_temp_dir, repo_path) = create_test_repo(); 723 let (_temp_dir, repo_path) = create_test_repo();
677 assert!(!commit_exists( 724 assert!(!commit_exists(
diff --git a/src/http/landing.rs b/src/http/landing.rs
index 5fc1e6e..042be5e 100644
--- a/src/http/landing.rs
+++ b/src/http/landing.rs
@@ -2,6 +2,7 @@
2/// 2///
3/// Generates HTML landing page for the Nostr relay. 3/// Generates HTML landing page for the Nostr relay.
4use crate::config::Config; 4use crate::config::Config;
5use crate::git::percent_encode;
5use crate::http::nip11::RelayInformationDocument; 6use crate::http::nip11::RelayInformationDocument;
6use std::collections::HashMap; 7use std::collections::HashMap;
7 8
@@ -847,7 +848,7 @@ pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String {
847 <div class="card"> 848 <div class="card">
848 <div class="clone-box"> 849 <div class="clone-box">
849 <div class="clone-line"><span class="cmd">curl -Ls https://ngit.dev/install.sh | bash</span></div> 850 <div class="clone-line"><span class="cmd">curl -Ls https://ngit.dev/install.sh | bash</span></div>
850 <div class="clone-line"><span class="cmd">git clone</span> <span class="url" id="nostr-clone-url">nostr://{{npub}}/<span id="relayref"></span>/{{identifier}}</span></div> 851 <div class="clone-line"><span class="cmd">git clone</span> <span class="url" id="nostr-clone-url">nostr://{npub}/<span id="relayref"></span>/{encoded_identifier}</span></div>
851 </div> 852 </div>
852 </div> 853 </div>
853 </div> 854 </div>
@@ -867,7 +868,7 @@ pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String {
867 868
868 // Construct gitworkshop link: gitworkshop.dev/npub/relayref/identifier 869 // Construct gitworkshop link: gitworkshop.dev/npub/relayref/identifier
869 const gitworkshopLink = document.getElementById('gitworkshop-link'); 870 const gitworkshopLink = document.getElementById('gitworkshop-link');
870 gitworkshopLink.setAttribute('href', 'https://gitworkshop.dev/{npub}/' + relayref + '/{identifier}'); 871 gitworkshopLink.setAttribute('href', 'https://gitworkshop.dev/{npub}/' + relayref + '/{encoded_identifier}');
871 872
872 // Set footer domain 873 // Set footer domain
873 var footerDomain = document.getElementById('footer-domain'); 874 var footerDomain = document.getElementById('footer-domain');
@@ -882,6 +883,7 @@ pub fn get_repo_html(config: &Config, npub: &str, identifier: &str) -> String {
882 relay_name = config.relay_name(), 883 relay_name = config.relay_name(),
883 npub = npub, 884 npub = npub,
884 identifier = identifier, 885 identifier = identifier,
886 encoded_identifier = percent_encode(identifier),
885 version = get_version(), 887 version = get_version(),
886 theme_toggle = get_theme_toggle_html(), 888 theme_toggle = get_theme_toggle_html(),
887 theme_script = get_theme_script(), 889 theme_script = get_theme_script(),
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
index 88ed6ae..77a9d9f 100644
--- a/src/nostr/events.rs
+++ b/src/nostr/events.rs
@@ -361,19 +361,20 @@ impl RepositoryState {
361 } 361 }
362} 362}
363 363
364/// Validate that a repository identifier is safe for use as a filesystem path component 364/// Validate that a repository identifier is safe for use as a filesystem path component.
365/// and as a URL path segment without percent-encoding.
366/// 365///
367/// Rejects identifiers that: 366/// NIP-34 places no restriction on `d` tag characters beyond NIP-01 (any string).
368/// - Are empty 367/// Identifiers are stored on disk verbatim and percent-encoded when used in URLs
369/// - Contain path separators (`/`, `\`) 368/// (per NIP-34 `nostr://` spec and GRASP-01 HTTP path spec). This function only
370/// - Contain null bytes 369/// rejects identifiers that cannot safely be stored as a filesystem directory name:
371/// - Contain whitespace (spaces, tabs, newlines, etc.) — these require percent-encoding
372/// in URLs and cause a mismatch between the stored path and the URL-decoded request
373/// - Are `.` or `..` (directory traversal)
374/// 370///
375/// NIP-34 recommends kebab-case identifiers; this function enforces the minimum 371/// - Empty string
376/// safety constraints needed for correct filesystem and HTTP serving behaviour. 372/// - Path separators (`/`, `\`) — would escape the repository directory
373/// - Null bytes — rejected by most filesystems
374/// - `.` or `..` — reserved path components (directory traversal)
375///
376/// Whitespace and other characters that require percent-encoding in URLs are
377/// explicitly allowed here; callers are responsible for encoding them in URLs.
377pub fn validate_identifier(identifier: &str) -> Result<(), String> { 378pub fn validate_identifier(identifier: &str) -> Result<(), String> {
378 if identifier.is_empty() { 379 if identifier.is_empty() {
379 return Err("identifier must not be empty".to_string()); 380 return Err("identifier must not be empty".to_string());
@@ -397,12 +398,6 @@ pub fn validate_identifier(identifier: &str) -> Result<(), String> {
397 identifier 398 identifier
398 )); 399 ));
399 } 400 }
400 if ch.is_whitespace() {
401 return Err(format!(
402 "identifier '{}' contains whitespace — use hyphens instead (e.g. 'my-repo')",
403 identifier
404 ));
405 }
406 } 401 }
407 Ok(()) 402 Ok(())
408} 403}
@@ -1573,6 +1568,11 @@ mod tests {
1573 assert!(validate_identifier("my_repo").is_ok()); 1568 assert!(validate_identifier("my_repo").is_ok());
1574 assert!(validate_identifier("repo123").is_ok()); 1569 assert!(validate_identifier("repo123").is_ok());
1575 assert!(validate_identifier("kuboslopp").is_ok()); 1570 assert!(validate_identifier("kuboslopp").is_ok());
1571 // Whitespace is valid — identifiers are stored verbatim on disk and
1572 // percent-encoded when used in URLs (NIP-34 / GRASP-01)
1573 assert!(validate_identifier("kuboslopp by Shakespeare").is_ok());
1574 assert!(validate_identifier("my\trepo").is_ok());
1575 assert!(validate_identifier("my repo").is_ok());
1576 } 1576 }
1577 1577
1578 #[test] 1578 #[test]
@@ -1593,19 +1593,12 @@ mod tests {
1593 } 1593 }
1594 1594
1595 #[test] 1595 #[test]
1596 fn test_validate_identifier_rejects_whitespace() { 1596 fn test_validate_announcement_accepts_identifier_with_spaces() {
1597 assert!(validate_identifier("kuboslopp by Shakespeare").is_err());
1598 assert!(validate_identifier("my\trepo").is_err());
1599 assert!(validate_identifier("my\nrepo").is_err());
1600 }
1601
1602 #[test]
1603 fn test_validate_announcement_rejects_identifier_with_spaces() {
1604 use crate::config::Config; 1597 use crate::config::Config;
1605 use crate::nostr::policy::AnnouncementResult; 1598 use crate::nostr::policy::AnnouncementResult;
1606 1599
1607 let keys = create_test_keys(); 1600 let keys = create_test_keys();
1608 // Identifier contains spaces — should be rejected regardless of clone/relay tags 1601 // Identifier contains spaces — valid per NIP-34; must be percent-encoded in URLs
1609 let event = create_announcement_event( 1602 let event = create_announcement_event(
1610 &keys, 1603 &keys,
1611 "kuboslopp by Shakespeare", 1604 "kuboslopp by Shakespeare",
@@ -1618,14 +1611,10 @@ mod tests {
1618 ..Config::for_testing() 1611 ..Config::for_testing()
1619 }; 1612 };
1620 let result = validate_announcement(&event, &config); 1613 let result = validate_announcement(&event, &config);
1621 if let AnnouncementResult::Reject(reason) = result { 1614 assert!(
1622 assert!( 1615 matches!(result, AnnouncementResult::Accept),
1623 reason.contains("whitespace") || reason.contains("identifier"), 1616 "Expected Accept for identifier with spaces, got {:?}",
1624 "unexpected rejection reason: {}", 1617 result
1625 reason 1618 );
1626 );
1627 } else {
1628 panic!("Expected Reject for identifier with spaces, got {:?}", result);
1629 }
1630 } 1619 }
1631} 1620}