diff options
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | src/git/mod.rs | 51 | ||||
| -rw-r--r-- | src/http/landing.rs | 6 | ||||
| -rw-r--r-- | src/nostr/events.rs | 59 |
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`. | ||
| 459 | pub 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. |
| 4 | use crate::config::Config; | 4 | use crate::config::Config; |
| 5 | use crate::git::percent_encode; | ||
| 5 | use crate::http::nip11::RelayInformationDocument; | 6 | use crate::http::nip11::RelayInformationDocument; |
| 6 | use std::collections::HashMap; | 7 | use 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. | ||
| 377 | pub fn validate_identifier(identifier: &str) -> Result<(), String> { | 378 | pub 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 | } |