diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-21 13:28:37 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-01-21 13:38:11 +0000 |
| commit | 46fbcc0a4c8a8dbf6cd345d6eaa6fe33a82100bb (patch) | |
| tree | 6ab52486732077dbab80907d974c195b1e2f7216 | |
| parent | 780d09b0c1eb823f02fc61de6dbf99b2d5cefaca (diff) | |
feat: add archive-grasp-services configuration option
Enables relay operators to backup/archive specific GRASP servers by domain.
Includes configuration, validation, documentation, and integration tests.
| -rw-r--r-- | .env.example | 14 | ||||
| -rw-r--r-- | docs/reference/configuration.md | 80 | ||||
| -rw-r--r-- | nix/module.nix | 17 | ||||
| -rw-r--r-- | src/config.rs | 265 | ||||
| -rw-r--r-- | src/nostr/events.rs | 11 | ||||
| -rw-r--r-- | tests/archive_grasp_services.rs | 378 |
6 files changed, 753 insertions, 12 deletions
diff --git a/.env.example b/.env.example index a19a07d..e152b89 100644 --- a/.env.example +++ b/.env.example | |||
| @@ -191,6 +191,18 @@ | |||
| 191 | # NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust | 191 | # NGIT_ARCHIVE_WHITELIST=bitcoin-core,linux,rust |
| 192 | # NGIT_ARCHIVE_WHITELIST= | 192 | # NGIT_ARCHIVE_WHITELIST= |
| 193 | 193 | ||
| 194 | # Archive GRASP services: comma-separated list of GRASP server domains to archive | ||
| 195 | # Archives all repositories from the specified GRASP server domains | ||
| 196 | # Must be bare domains only (e.g., git.example.com, NOT wss://git.example.com) | ||
| 197 | # Mutually exclusive with NGIT_ARCHIVE_ALL and NGIT_ARCHIVE_WHITELIST | ||
| 198 | # Automatically sets NGIT_ARCHIVE_READ_ONLY to true by default | ||
| 199 | # CLI: --archive-grasp-services <list> | ||
| 200 | # Default: (empty) | ||
| 201 | # Examples: | ||
| 202 | # NGIT_ARCHIVE_GRASP_SERVICES=git.example.com | ||
| 203 | # NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev,relay.gitnostr.com | ||
| 204 | # NGIT_ARCHIVE_GRASP_SERVICES= | ||
| 205 | |||
| 194 | # Archive read-only mode (relay is read-only sync of archived repositories) | 206 | # Archive read-only mode (relay is read-only sync of archived repositories) |
| 195 | # When true: | 207 | # When true: |
| 196 | # - NIP-11 includes GRASP-05 in supported_grasps | 208 | # - NIP-11 includes GRASP-05 in supported_grasps |
| @@ -200,7 +212,7 @@ | |||
| 200 | # - Archive mode disabled (standard GRASP-01 operation) | 212 | # - Archive mode disabled (standard GRASP-01 operation) |
| 201 | # | 213 | # |
| 202 | # CLI: --archive-read-only | 214 | # CLI: --archive-read-only |
| 203 | # Default: true if NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set, false otherwise | 215 | # Default: true if NGIT_ARCHIVE_ALL, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES is set, false otherwise |
| 204 | # Note: Setting to true without archive config causes startup error | 216 | # Note: Setting to true without archive config causes startup error |
| 205 | # Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive) | 217 | # Note: Cannot be used with NGIT_REPOSITORY_WHITELIST (mutually exclusive) |
| 206 | # NGIT_ARCHIVE_READ_ONLY= | 218 | # NGIT_ARCHIVE_READ_ONLY= |
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c1cb712..b24b498 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md | |||
| @@ -574,11 +574,79 @@ NGIT_ARCHIVE_WHITELIST=npub1alice23...,npub1bob23.../linux,bitcoin-core | |||
| 574 | 574 | ||
| 575 | --- | 575 | --- |
| 576 | 576 | ||
| 577 | #### `NGIT_ARCHIVE_GRASP_SERVICES` | ||
| 578 | |||
| 579 | **Description:** Comma-separated list of GRASP server domains to archive | ||
| 580 | **Type:** String (comma-separated domain names) | ||
| 581 | **Default:** (empty) | ||
| 582 | **Required:** No | ||
| 583 | |||
| 584 | **Format:** | ||
| 585 | - `<domain>` - Archive all repositories from this GRASP server domain | ||
| 586 | - **Must be bare domains only** (e.g., `git.example.com`, NOT `wss://git.example.com`) | ||
| 587 | - Matching extracts domains from announcement clone URLs and compares them exactly (case-sensitive) | ||
| 588 | |||
| 589 | **Examples:** | ||
| 590 | |||
| 591 | ```bash | ||
| 592 | # Archive all repos from a single GRASP server | ||
| 593 | NGIT_ARCHIVE_GRASP_SERVICES=git.example.com | ||
| 594 | |||
| 595 | # Archive repos from multiple GRASP servers | ||
| 596 | NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev,relay.gitnostr.com | ||
| 597 | |||
| 598 | # Archive from localhost (testing) | ||
| 599 | NGIT_ARCHIVE_GRASP_SERVICES=localhost:7334 | ||
| 600 | ``` | ||
| 601 | |||
| 602 | **Validation:** | ||
| 603 | |||
| 604 | - Domain entries must be bare domains without scheme prefixes (ws://, wss://, https://, etc.) | ||
| 605 | - Whitespace is trimmed | ||
| 606 | - Empty entries are ignored | ||
| 607 | - **Mutually exclusive** with `NGIT_ARCHIVE_ALL` and `NGIT_ARCHIVE_WHITELIST` | ||
| 608 | |||
| 609 | **Security Notes:** | ||
| 610 | |||
| 611 | - Archives ALL repositories from the specified GRASP server domains | ||
| 612 | - Use with caution - ensure you trust the GRASP servers you're archiving from | ||
| 613 | - Storage requirements depend on the size of repositories on the archived servers | ||
| 614 | - Automatically sets `NGIT_ARCHIVE_READ_ONLY=true` by default | ||
| 615 | |||
| 616 | **Error Conditions:** | ||
| 617 | |||
| 618 | ```bash | ||
| 619 | # ERROR: Cannot use with NGIT_ARCHIVE_ALL | ||
| 620 | NGIT_ARCHIVE_GRASP_SERVICES=git.example.com | ||
| 621 | NGIT_ARCHIVE_ALL=true | ||
| 622 | # → Server fails to start: "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with | ||
| 623 | # NGIT_ARCHIVE_ALL=true. These options are mutually exclusive." | ||
| 624 | |||
| 625 | # ERROR: Cannot use with NGIT_ARCHIVE_WHITELIST | ||
| 626 | NGIT_ARCHIVE_GRASP_SERVICES=git.example.com | ||
| 627 | NGIT_ARCHIVE_WHITELIST=npub1alice... | ||
| 628 | # → Server fails to start: "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with | ||
| 629 | # NGIT_ARCHIVE_WHITELIST. These options are mutually exclusive." | ||
| 630 | ``` | ||
| 631 | |||
| 632 | **Use Cases:** | ||
| 633 | |||
| 634 | ```bash | ||
| 635 | # Backup/mirror a specific GRASP server | ||
| 636 | NGIT_ARCHIVE_GRASP_SERVICES=git.example.com | ||
| 637 | NGIT_ARCHIVE_READ_ONLY=true # Default | ||
| 638 | |||
| 639 | # Archive multiple trusted GRASP servers | ||
| 640 | NGIT_ARCHIVE_GRASP_SERVICES=git.nostr.dev,relay.gitnostr.com | ||
| 641 | ``` | ||
| 642 | |||
| 643 | --- | ||
| 644 | |||
| 577 | #### `NGIT_ARCHIVE_READ_ONLY` | 645 | #### `NGIT_ARCHIVE_READ_ONLY` |
| 578 | 646 | ||
| 579 | **Description:** Configure relay as read-only sync of archived repositories | 647 | **Description:** Configure relay as read-only sync of archived repositories |
| 580 | **Type:** Boolean | 648 | **Type:** Boolean |
| 581 | **Default:** `true` if `NGIT_ARCHIVE_ALL` or `NGIT_ARCHIVE_WHITELIST` is set, `false` otherwise | 649 | **Default:** `true` if `NGIT_ARCHIVE_ALL`, `NGIT_ARCHIVE_WHITELIST`, or `NGIT_ARCHIVE_GRASP_SERVICES` is set, `false` otherwise |
| 582 | **Required:** No | 650 | **Required:** No |
| 583 | 651 | ||
| 584 | **Examples:** | 652 | **Examples:** |
| @@ -591,7 +659,7 @@ NGIT_ARCHIVE_READ_ONLY=true | |||
| 591 | NGIT_ARCHIVE_READ_ONLY=false | 659 | NGIT_ARCHIVE_READ_ONLY=false |
| 592 | 660 | ||
| 593 | # Automatic (default behavior) | 661 | # Automatic (default behavior) |
| 594 | # - If NGIT_ARCHIVE_ALL or NGIT_ARCHIVE_WHITELIST is set → true | 662 | # - If NGIT_ARCHIVE_ALL, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES is set → true |
| 595 | # - Otherwise → false | 663 | # - Otherwise → false |
| 596 | # NGIT_ARCHIVE_READ_ONLY= | 664 | # NGIT_ARCHIVE_READ_ONLY= |
| 597 | ``` | 665 | ``` |
| @@ -615,8 +683,9 @@ NGIT_ARCHIVE_READ_ONLY=false | |||
| 615 | NGIT_ARCHIVE_READ_ONLY=true | 683 | NGIT_ARCHIVE_READ_ONLY=true |
| 616 | NGIT_ARCHIVE_ALL=false | 684 | NGIT_ARCHIVE_ALL=false |
| 617 | NGIT_ARCHIVE_WHITELIST= | 685 | NGIT_ARCHIVE_WHITELIST= |
| 686 | NGIT_ARCHIVE_GRASP_SERVICES= | ||
| 618 | # → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either | 687 | # → Server fails to start: "NGIT_ARCHIVE_READ_ONLY=true requires either |
| 619 | # NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" | 688 | # NGIT_ARCHIVE_ALL=true, NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set" |
| 620 | 689 | ||
| 621 | # ERROR: Cannot use repository whitelist with archive read-only | 690 | # ERROR: Cannot use repository whitelist with archive read-only |
| 622 | NGIT_ARCHIVE_READ_ONLY=true | 691 | NGIT_ARCHIVE_READ_ONLY=true |
| @@ -633,6 +702,7 @@ When `NGIT_ARCHIVE_READ_ONLY=true`: | |||
| 633 | - `curation`: Set to one of: | 702 | - `curation`: Set to one of: |
| 634 | - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`) | 703 | - `"Read-only sync of all repositories found on network"` (if `NGIT_ARCHIVE_ALL=true`) |
| 635 | - `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set) | 704 | - `"Read-only sync of whitelisted repositories and maintainers"` (if `NGIT_ARCHIVE_WHITELIST` set) |
| 705 | - `"Read-only sync of repositories from specified GRASP servers"` (if `NGIT_ARCHIVE_GRASP_SERVICES` set) | ||
| 636 | 706 | ||
| 637 | **Use Cases:** | 707 | **Use Cases:** |
| 638 | 708 | ||
| @@ -648,6 +718,10 @@ NGIT_ARCHIVE_READ_ONLY=true # Default | |||
| 648 | # Writable mirror (advanced, not typical) | 718 | # Writable mirror (advanced, not typical) |
| 649 | NGIT_ARCHIVE_WHITELIST=npub1alice... | 719 | NGIT_ARCHIVE_WHITELIST=npub1alice... |
| 650 | NGIT_ARCHIVE_READ_ONLY=false | 720 | NGIT_ARCHIVE_READ_ONLY=false |
| 721 | |||
| 722 | # Archive specific GRASP servers | ||
| 723 | NGIT_ARCHIVE_GRASP_SERVICES=git.example.com,git.nostr.dev | ||
| 724 | NGIT_ARCHIVE_READ_ONLY=true # Default | ||
| 651 | ``` | 725 | ``` |
| 652 | 726 | ||
| 653 | --- | 727 | --- |
diff --git a/nix/module.nix b/nix/module.nix index 40bc868..564259e 100644 --- a/nix/module.nix +++ b/nix/module.nix | |||
| @@ -196,6 +196,19 @@ let | |||
| 196 | ''; | 196 | ''; |
| 197 | }; | 197 | }; |
| 198 | 198 | ||
| 199 | archiveGraspServices = mkOption { | ||
| 200 | type = types.listOf types.str; | ||
| 201 | default = [ ]; | ||
| 202 | example = [ "git.example.com" "git.nostr.dev" ]; | ||
| 203 | description = '' | ||
| 204 | GRASP-05 archive GRASP services: list of GRASP server domains to archive. | ||
| 205 | Archives all repositories from the specified GRASP server domains. | ||
| 206 | Must be bare domains only (e.g., git.example.com, NOT wss://git.example.com). | ||
| 207 | Mutually exclusive with archiveAll and archiveWhitelist. | ||
| 208 | Automatically sets archiveReadOnly to true by default. | ||
| 209 | ''; | ||
| 210 | }; | ||
| 211 | |||
| 199 | archiveReadOnly = mkOption { | 212 | archiveReadOnly = mkOption { |
| 200 | type = types.nullOr types.bool; | 213 | type = types.nullOr types.bool; |
| 201 | default = null; | 214 | default = null; |
| @@ -205,7 +218,7 @@ let | |||
| 205 | - NIP-11 includes GRASP-05 in supported_grasps | 218 | - NIP-11 includes GRASP-05 in supported_grasps |
| 206 | - NIP-11 curation field describes archive scope | 219 | - NIP-11 curation field describes archive scope |
| 207 | - Repository announcements not listing this service are accepted per whitelist/archive-all | 220 | - Repository announcements not listing this service are accepted per whitelist/archive-all |
| 208 | Default: true if archiveAll or archiveWhitelist is set, false otherwise | 221 | Default: true if archiveAll, archiveWhitelist, or archiveGraspServices is set, false otherwise |
| 209 | Note: Setting to true without archive config causes startup error | 222 | Note: Setting to true without archive config causes startup error |
| 210 | Note: Cannot be used with repositoryWhitelist (mutually exclusive) | 223 | Note: Cannot be used with repositoryWhitelist (mutually exclusive) |
| 211 | ''; | 224 | ''; |
| @@ -298,6 +311,8 @@ let | |||
| 298 | toString cfg.naughtyListExpirationHours; | 311 | toString cfg.naughtyListExpirationHours; |
| 299 | NGIT_ARCHIVE_ALL = if cfg.archiveAll then "true" else "false"; | 312 | NGIT_ARCHIVE_ALL = if cfg.archiveAll then "true" else "false"; |
| 300 | NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; | 313 | NGIT_ARCHIVE_WHITELIST = concatStringsSep "," cfg.archiveWhitelist; |
| 314 | NGIT_ARCHIVE_GRASP_SERVICES = | ||
| 315 | concatStringsSep "," cfg.archiveGraspServices; | ||
| 301 | NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; | 316 | NGIT_REPOSITORY_WHITELIST = concatStringsSep "," cfg.repositoryWhitelist; |
| 302 | NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; | 317 | NGIT_REPOSITORY_BLACKLIST = concatStringsSep "," cfg.repositoryBlacklist; |
| 303 | NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist; | 318 | NGIT_EVENT_BLACKLIST = concatStringsSep "," cfg.eventBlacklist; |
diff --git a/src/config.rs b/src/config.rs index 0a867e3..4b1e8d9 100644 --- a/src/config.rs +++ b/src/config.rs | |||
| @@ -122,6 +122,11 @@ pub struct ArchiveConfig { | |||
| 122 | /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). | 122 | /// If empty and archive_all is false, GRASP-05 is disabled (GRASP-01 strict mode). |
| 123 | pub whitelist: Vec<WhitelistEntry>, | 123 | pub whitelist: Vec<WhitelistEntry>, |
| 124 | 124 | ||
| 125 | /// GRASP server domains to archive (archive all repositories from these domains) | ||
| 126 | /// | ||
| 127 | /// If non-empty, archives all repositories from the specified GRASP server domains. | ||
| 128 | pub grasp_services: Vec<String>, | ||
| 129 | |||
| 125 | /// Read-only archive mode: relay is a read-only sync of archived repositories | 130 | /// Read-only archive mode: relay is a read-only sync of archived repositories |
| 126 | /// | 131 | /// |
| 127 | /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. | 132 | /// When true, the relay ONLY accepts announcements matching the archive whitelist/all. |
| @@ -131,9 +136,9 @@ pub struct ArchiveConfig { | |||
| 131 | } | 136 | } |
| 132 | 137 | ||
| 133 | impl ArchiveConfig { | 138 | impl ArchiveConfig { |
| 134 | /// Check if GRASP-05 is enabled (either archive_all or non-empty whitelist) | 139 | /// Check if GRASP-05 is enabled (either archive_all, non-empty whitelist, or non-empty grasp_services) |
| 135 | pub fn enabled(&self) -> bool { | 140 | pub fn enabled(&self) -> bool { |
| 136 | self.archive_all || !self.whitelist.is_empty() | 141 | self.archive_all || !self.whitelist.is_empty() || !self.grasp_services.is_empty() |
| 137 | } | 142 | } |
| 138 | 143 | ||
| 139 | /// Check if an announcement matches the archive configuration | 144 | /// Check if an announcement matches the archive configuration |
| @@ -141,6 +146,7 @@ impl ArchiveConfig { | |||
| 141 | /// Returns true if: | 146 | /// Returns true if: |
| 142 | /// - archive_all is true, OR | 147 | /// - archive_all is true, OR |
| 143 | /// - announcement matches any whitelist entry | 148 | /// - announcement matches any whitelist entry |
| 149 | /// Note: grasp_services matching is handled via matches_grasp_services() | ||
| 144 | pub fn matches(&self, npub: &str, identifier: &str) -> bool { | 150 | pub fn matches(&self, npub: &str, identifier: &str) -> bool { |
| 145 | if self.archive_all { | 151 | if self.archive_all { |
| 146 | return true; | 152 | return true; |
| @@ -150,6 +156,19 @@ impl ArchiveConfig { | |||
| 150 | .iter() | 156 | .iter() |
| 151 | .any(|entry| entry.matches(npub, identifier)) | 157 | .any(|entry| entry.matches(npub, identifier)) |
| 152 | } | 158 | } |
| 159 | |||
| 160 | /// Check if any of the given domains match the configured grasp_services | ||
| 161 | /// | ||
| 162 | /// Returns true if any domain in the list matches any configured grasp_services entry. | ||
| 163 | pub fn matches_grasp_services(&self, domains: &[String]) -> bool { | ||
| 164 | if self.grasp_services.is_empty() { | ||
| 165 | return false; | ||
| 166 | } | ||
| 167 | |||
| 168 | domains | ||
| 169 | .iter() | ||
| 170 | .any(|domain| self.grasp_services.iter().any(|service| service == domain)) | ||
| 171 | } | ||
| 153 | } | 172 | } |
| 154 | 173 | ||
| 155 | impl Default for ArchiveConfig { | 174 | impl Default for ArchiveConfig { |
| @@ -157,6 +176,7 @@ impl Default for ArchiveConfig { | |||
| 157 | Self { | 176 | Self { |
| 158 | archive_all: false, | 177 | archive_all: false, |
| 159 | whitelist: Vec::new(), | 178 | whitelist: Vec::new(), |
| 179 | grasp_services: Vec::new(), | ||
| 160 | read_only: false, | 180 | read_only: false, |
| 161 | } | 181 | } |
| 162 | } | 182 | } |
| @@ -447,9 +467,15 @@ pub struct Config { | |||
| 447 | #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] | 467 | #[arg(long, env = "NGIT_ARCHIVE_WHITELIST", default_value = "")] |
| 448 | pub archive_whitelist: String, | 468 | pub archive_whitelist: String, |
| 449 | 469 | ||
| 470 | /// GRASP-05 archive GRASP services: comma-separated list of GRASP server domains to archive | ||
| 471 | /// When set, archives all repositories from the specified GRASP server domains | ||
| 472 | /// Mutually exclusive with archive_all and archive_whitelist | ||
| 473 | #[arg(long, env = "NGIT_ARCHIVE_GRASP_SERVICES", default_value = "")] | ||
| 474 | pub archive_grasp_services: String, | ||
| 475 | |||
| 450 | /// Archive read-only mode: relay is a read-only sync of archived repositories | 476 | /// Archive read-only mode: relay is a read-only sync of archived repositories |
| 451 | /// Defaults to true if archive_all or archive_whitelist is set, false otherwise | 477 | /// Defaults to true if archive_all, archive_whitelist, or archive_grasp_services is set, false otherwise |
| 452 | /// Throws error if set to true without archive_all or archive_whitelist | 478 | /// Throws error if set to true without archive_all, archive_whitelist, or archive_grasp_services |
| 453 | #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] | 479 | #[arg(long, env = "NGIT_ARCHIVE_READ_ONLY")] |
| 454 | pub archive_read_only: Option<bool>, | 480 | pub archive_read_only: Option<bool>, |
| 455 | 481 | ||
| @@ -589,13 +615,32 @@ impl Config { | |||
| 589 | 615 | ||
| 590 | // Validate archive configuration | 616 | // Validate archive configuration |
| 591 | let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); | 617 | let archive_whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); |
| 592 | let archive_enabled = self.archive_all || !archive_whitelist.is_empty(); | 618 | let archive_grasp_services = self.parse_archive_grasp_services(); |
| 619 | let archive_enabled = | ||
| 620 | self.archive_all || !archive_whitelist.is_empty() || !archive_grasp_services.is_empty(); | ||
| 621 | |||
| 622 | // Fatal error: archive_grasp_services cannot be used with archive_all or archive_whitelist | ||
| 623 | if !archive_grasp_services.is_empty() { | ||
| 624 | if self.archive_all { | ||
| 625 | return Err(anyhow!( | ||
| 626 | "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_ALL=true. \ | ||
| 627 | These options are mutually exclusive." | ||
| 628 | )); | ||
| 629 | } | ||
| 630 | if !archive_whitelist.is_empty() { | ||
| 631 | return Err(anyhow!( | ||
| 632 | "NGIT_ARCHIVE_GRASP_SERVICES cannot be used with NGIT_ARCHIVE_WHITELIST. \ | ||
| 633 | These options are mutually exclusive." | ||
| 634 | )); | ||
| 635 | } | ||
| 636 | } | ||
| 593 | 637 | ||
| 594 | // Fatal error: archive_read_only=true without archive mode enabled | 638 | // Fatal error: archive_read_only=true without archive mode enabled |
| 595 | if let Some(true) = self.archive_read_only { | 639 | if let Some(true) = self.archive_read_only { |
| 596 | if !archive_enabled { | 640 | if !archive_enabled { |
| 597 | return Err(anyhow!( | 641 | return Err(anyhow!( |
| 598 | "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true or NGIT_ARCHIVE_WHITELIST to be set" | 642 | "NGIT_ARCHIVE_READ_ONLY=true requires either NGIT_ARCHIVE_ALL=true, \ |
| 643 | NGIT_ARCHIVE_WHITELIST, or NGIT_ARCHIVE_GRASP_SERVICES to be set" | ||
| 599 | )); | 644 | )); |
| 600 | } | 645 | } |
| 601 | } | 646 | } |
| @@ -619,13 +664,32 @@ impl Config { | |||
| 619 | Ok(()) | 664 | Ok(()) |
| 620 | } | 665 | } |
| 621 | 666 | ||
| 667 | /// Parse archive GRASP services from comma-separated string | ||
| 668 | /// | ||
| 669 | /// Returns a list of domain names (GRASP server domains to archive). | ||
| 670 | /// Whitespace is trimmed and empty entries are ignored. | ||
| 671 | pub fn parse_archive_grasp_services(&self) -> Vec<String> { | ||
| 672 | if self.archive_grasp_services.trim().is_empty() { | ||
| 673 | return Vec::new(); | ||
| 674 | } | ||
| 675 | |||
| 676 | self.archive_grasp_services | ||
| 677 | .split(',') | ||
| 678 | .map(|s| s.trim()) | ||
| 679 | .filter(|s| !s.is_empty()) | ||
| 680 | .map(|s| s.to_string()) | ||
| 681 | .collect() | ||
| 682 | } | ||
| 683 | |||
| 622 | /// Get parsed archive configuration with computed read-only mode | 684 | /// Get parsed archive configuration with computed read-only mode |
| 623 | /// | 685 | /// |
| 624 | /// Read-only mode defaults to true if archive mode is enabled, false otherwise. | 686 | /// Read-only mode defaults to true if archive mode is enabled, false otherwise. |
| 625 | /// This method assumes config has been validated - call Config::validate() first! | 687 | /// This method assumes config has been validated - call Config::validate() first! |
| 626 | pub fn archive_config(&self) -> ArchiveConfig { | 688 | pub fn archive_config(&self) -> ArchiveConfig { |
| 627 | let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); | 689 | let whitelist = WhitelistEntry::parse_whitelist(&self.archive_whitelist); |
| 628 | let archive_enabled = self.archive_all || !whitelist.is_empty(); | 690 | let archive_grasp_services = self.parse_archive_grasp_services(); |
| 691 | let archive_enabled = | ||
| 692 | self.archive_all || !whitelist.is_empty() || !archive_grasp_services.is_empty(); | ||
| 629 | 693 | ||
| 630 | let read_only = match self.archive_read_only { | 694 | let read_only = match self.archive_read_only { |
| 631 | Some(true) => true, // Already validated in validate() | 695 | Some(true) => true, // Already validated in validate() |
| @@ -639,6 +703,7 @@ impl Config { | |||
| 639 | ArchiveConfig { | 703 | ArchiveConfig { |
| 640 | archive_all: self.archive_all, | 704 | archive_all: self.archive_all, |
| 641 | whitelist, | 705 | whitelist, |
| 706 | grasp_services: archive_grasp_services, | ||
| 642 | read_only, | 707 | read_only, |
| 643 | } | 708 | } |
| 644 | } | 709 | } |
| @@ -705,6 +770,7 @@ impl Config { | |||
| 705 | naughty_list_expiration_hours: 12, | 770 | naughty_list_expiration_hours: 12, |
| 706 | archive_all: false, | 771 | archive_all: false, |
| 707 | archive_whitelist: String::new(), | 772 | archive_whitelist: String::new(), |
| 773 | archive_grasp_services: String::new(), | ||
| 708 | archive_read_only: None, | 774 | archive_read_only: None, |
| 709 | repository_whitelist: String::new(), | 775 | repository_whitelist: String::new(), |
| 710 | repository_blacklist: String::new(), | 776 | repository_blacklist: String::new(), |
| @@ -936,6 +1002,7 @@ mod tests { | |||
| 936 | let config = ArchiveConfig { | 1002 | let config = ArchiveConfig { |
| 937 | archive_all: true, | 1003 | archive_all: true, |
| 938 | whitelist: Vec::new(), | 1004 | whitelist: Vec::new(), |
| 1005 | grasp_services: Vec::new(), | ||
| 939 | read_only: true, | 1006 | read_only: true, |
| 940 | }; | 1007 | }; |
| 941 | assert!(config.enabled()); | 1008 | assert!(config.enabled()); |
| @@ -943,6 +1010,7 @@ mod tests { | |||
| 943 | let config = ArchiveConfig { | 1010 | let config = ArchiveConfig { |
| 944 | archive_all: false, | 1011 | archive_all: false, |
| 945 | whitelist: vec![WhitelistEntry::Identifier("test".into())], | 1012 | whitelist: vec![WhitelistEntry::Identifier("test".into())], |
| 1013 | grasp_services: Vec::new(), | ||
| 946 | read_only: true, | 1014 | read_only: true, |
| 947 | }; | 1015 | }; |
| 948 | assert!(config.enabled()); | 1016 | assert!(config.enabled()); |
| @@ -958,6 +1026,7 @@ mod tests { | |||
| 958 | WhitelistEntry::Pubkey(test_npub.clone()), | 1026 | WhitelistEntry::Pubkey(test_npub.clone()), |
| 959 | WhitelistEntry::Identifier("bitcoin-core".into()), | 1027 | WhitelistEntry::Identifier("bitcoin-core".into()), |
| 960 | ], | 1028 | ], |
| 1029 | grasp_services: Vec::new(), | ||
| 961 | read_only: false, | 1030 | read_only: false, |
| 962 | }; | 1031 | }; |
| 963 | 1032 | ||
| @@ -971,6 +1040,7 @@ mod tests { | |||
| 971 | let config = ArchiveConfig { | 1040 | let config = ArchiveConfig { |
| 972 | archive_all: true, | 1041 | archive_all: true, |
| 973 | whitelist: Vec::new(), | 1042 | whitelist: Vec::new(), |
| 1043 | grasp_services: Vec::new(), | ||
| 974 | read_only: true, | 1044 | read_only: true, |
| 975 | }; | 1045 | }; |
| 976 | 1046 | ||
| @@ -1379,4 +1449,185 @@ mod tests { | |||
| 1379 | let result = config.check(&allowed_npub); | 1449 | let result = config.check(&allowed_npub); |
| 1380 | assert!(result.is_none()); | 1450 | assert!(result.is_none()); |
| 1381 | } | 1451 | } |
| 1452 | |||
| 1453 | #[test] | ||
| 1454 | fn test_parse_archive_grasp_services_empty() { | ||
| 1455 | let config = Config::for_testing(); | ||
| 1456 | let services = config.parse_archive_grasp_services(); | ||
| 1457 | assert!(services.is_empty()); | ||
| 1458 | |||
| 1459 | let config = Config { | ||
| 1460 | archive_grasp_services: " ".to_string(), | ||
| 1461 | ..Config::for_testing() | ||
| 1462 | }; | ||
| 1463 | let services = config.parse_archive_grasp_services(); | ||
| 1464 | assert!(services.is_empty()); | ||
| 1465 | } | ||
| 1466 | |||
| 1467 | #[test] | ||
| 1468 | fn test_parse_archive_grasp_services_single() { | ||
| 1469 | let config = Config { | ||
| 1470 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1471 | ..Config::for_testing() | ||
| 1472 | }; | ||
| 1473 | let services = config.parse_archive_grasp_services(); | ||
| 1474 | assert_eq!(services.len(), 1); | ||
| 1475 | assert_eq!(services[0], "git.example.com"); | ||
| 1476 | } | ||
| 1477 | |||
| 1478 | #[test] | ||
| 1479 | fn test_parse_archive_grasp_services_multiple() { | ||
| 1480 | let config = Config { | ||
| 1481 | archive_grasp_services: "git.example.com,git.nostr.dev,relay.gitnostr.com".to_string(), | ||
| 1482 | ..Config::for_testing() | ||
| 1483 | }; | ||
| 1484 | let services = config.parse_archive_grasp_services(); | ||
| 1485 | assert_eq!(services.len(), 3); | ||
| 1486 | assert_eq!(services[0], "git.example.com"); | ||
| 1487 | assert_eq!(services[1], "git.nostr.dev"); | ||
| 1488 | assert_eq!(services[2], "relay.gitnostr.com"); | ||
| 1489 | } | ||
| 1490 | |||
| 1491 | #[test] | ||
| 1492 | fn test_parse_archive_grasp_services_with_whitespace() { | ||
| 1493 | let config = Config { | ||
| 1494 | archive_grasp_services: " git.example.com , git.nostr.dev , relay.gitnostr.com " | ||
| 1495 | .to_string(), | ||
| 1496 | ..Config::for_testing() | ||
| 1497 | }; | ||
| 1498 | let services = config.parse_archive_grasp_services(); | ||
| 1499 | assert_eq!(services.len(), 3); | ||
| 1500 | assert_eq!(services[0], "git.example.com"); | ||
| 1501 | assert_eq!(services[1], "git.nostr.dev"); | ||
| 1502 | assert_eq!(services[2], "relay.gitnostr.com"); | ||
| 1503 | } | ||
| 1504 | |||
| 1505 | #[test] | ||
| 1506 | fn test_archive_grasp_services_validation_error_with_archive_all() { | ||
| 1507 | let config = Config { | ||
| 1508 | archive_all: true, | ||
| 1509 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1510 | ..Config::for_testing() | ||
| 1511 | }; | ||
| 1512 | let result = config.validate(); | ||
| 1513 | assert!(result.is_err()); | ||
| 1514 | let err = result.unwrap_err().to_string(); | ||
| 1515 | assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES")); | ||
| 1516 | assert!(err.contains("NGIT_ARCHIVE_ALL")); | ||
| 1517 | assert!(err.contains("mutually exclusive")); | ||
| 1518 | } | ||
| 1519 | |||
| 1520 | #[test] | ||
| 1521 | fn test_archive_grasp_services_validation_error_with_archive_whitelist() { | ||
| 1522 | let keys = Keys::generate(); | ||
| 1523 | let test_npub = keys.public_key().to_bech32().unwrap(); | ||
| 1524 | let config = Config { | ||
| 1525 | archive_whitelist: test_npub, | ||
| 1526 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1527 | ..Config::for_testing() | ||
| 1528 | }; | ||
| 1529 | let result = config.validate(); | ||
| 1530 | assert!(result.is_err()); | ||
| 1531 | let err = result.unwrap_err().to_string(); | ||
| 1532 | assert!(err.contains("NGIT_ARCHIVE_GRASP_SERVICES")); | ||
| 1533 | assert!(err.contains("NGIT_ARCHIVE_WHITELIST")); | ||
| 1534 | assert!(err.contains("mutually exclusive")); | ||
| 1535 | } | ||
| 1536 | |||
| 1537 | #[test] | ||
| 1538 | fn test_archive_grasp_services_enables_archive_mode() { | ||
| 1539 | let config = Config { | ||
| 1540 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1541 | ..Config::for_testing() | ||
| 1542 | }; | ||
| 1543 | let archive_config = config.archive_config(); | ||
| 1544 | assert!(archive_config.enabled()); | ||
| 1545 | assert_eq!(archive_config.read_only, true); // Default to true | ||
| 1546 | } | ||
| 1547 | |||
| 1548 | #[test] | ||
| 1549 | fn test_archive_grasp_services_read_only_default() { | ||
| 1550 | // Default: true when archive_grasp_services is set | ||
| 1551 | let config = Config { | ||
| 1552 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1553 | ..Config::for_testing() | ||
| 1554 | }; | ||
| 1555 | assert_eq!(config.archive_config().read_only, true); | ||
| 1556 | } | ||
| 1557 | |||
| 1558 | #[test] | ||
| 1559 | fn test_archive_grasp_services_read_only_explicit_false() { | ||
| 1560 | // Explicit false should be respected | ||
| 1561 | let config = Config { | ||
| 1562 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1563 | archive_read_only: Some(false), | ||
| 1564 | ..Config::for_testing() | ||
| 1565 | }; | ||
| 1566 | assert_eq!(config.archive_config().read_only, false); | ||
| 1567 | } | ||
| 1568 | |||
| 1569 | #[test] | ||
| 1570 | fn test_archive_read_only_validation_with_grasp_services() { | ||
| 1571 | // Should succeed with archive_grasp_services set | ||
| 1572 | let config = Config { | ||
| 1573 | archive_grasp_services: "git.example.com".to_string(), | ||
| 1574 | archive_read_only: Some(true), | ||
| 1575 | ..Config::for_testing() | ||
| 1576 | }; | ||
| 1577 | assert!(config.validate().is_ok()); | ||
| 1578 | } | ||
| 1579 | |||
| 1580 | #[test] | ||
| 1581 | fn test_archive_config_matches_grasp_services() { | ||
| 1582 | let config = ArchiveConfig { | ||
| 1583 | archive_all: false, | ||
| 1584 | whitelist: Vec::new(), | ||
| 1585 | grasp_services: vec!["git.example.com".to_string(), "gitlab.org".to_string()], | ||
| 1586 | read_only: true, | ||
| 1587 | }; | ||
| 1588 | |||
| 1589 | // Should match configured services | ||
| 1590 | assert!(config.matches_grasp_services(&["git.example.com".to_string()])); | ||
| 1591 | assert!(config.matches_grasp_services(&["gitlab.org".to_string()])); | ||
| 1592 | |||
| 1593 | // Should not match unconfigured services | ||
| 1594 | assert!(!config.matches_grasp_services(&["github.com".to_string()])); | ||
| 1595 | assert!(!config.matches_grasp_services(&["other.com".to_string()])); | ||
| 1596 | } | ||
| 1597 | |||
| 1598 | #[test] | ||
| 1599 | fn test_archive_config_matches_grasp_services_empty() { | ||
| 1600 | let config = ArchiveConfig { | ||
| 1601 | archive_all: false, | ||
| 1602 | whitelist: Vec::new(), | ||
| 1603 | grasp_services: Vec::new(), | ||
| 1604 | read_only: true, | ||
| 1605 | }; | ||
| 1606 | |||
| 1607 | // Should not match anything when grasp_services is empty | ||
| 1608 | assert!(!config.matches_grasp_services(&["git.example.com".to_string()])); | ||
| 1609 | assert!(!config.matches_grasp_services(&[])); | ||
| 1610 | } | ||
| 1611 | |||
| 1612 | #[test] | ||
| 1613 | fn test_archive_config_matches_grasp_services_multiple_domains() { | ||
| 1614 | let config = ArchiveConfig { | ||
| 1615 | archive_all: false, | ||
| 1616 | whitelist: Vec::new(), | ||
| 1617 | grasp_services: vec!["git.example.com".to_string()], | ||
| 1618 | read_only: true, | ||
| 1619 | }; | ||
| 1620 | |||
| 1621 | // Should match if any domain matches | ||
| 1622 | assert!(config.matches_grasp_services(&[ | ||
| 1623 | "github.com".to_string(), | ||
| 1624 | "git.example.com".to_string(), | ||
| 1625 | "gitlab.org".to_string(), | ||
| 1626 | ])); | ||
| 1627 | |||
| 1628 | // Should not match if no domain matches | ||
| 1629 | assert!( | ||
| 1630 | !config.matches_grasp_services(&["github.com".to_string(), "gitlab.org".to_string(),]) | ||
| 1631 | ); | ||
| 1632 | } | ||
| 1382 | } | 1633 | } |
diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 1d5a50f..718633e 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs | |||
| @@ -436,6 +436,17 @@ pub fn validate_announcement( | |||
| 436 | return AnnouncementResult::AcceptArchive; | 436 | return AnnouncementResult::AcceptArchive; |
| 437 | } | 437 | } |
| 438 | 438 | ||
| 439 | // GRASP-05: Archive mode - accept if announcement lists any configured GRASP service in clone URLs | ||
| 440 | // Only check clone URLs (not relays) since we're archiving from OTHER services | ||
| 441 | // Check if announcement matches any configured GRASP service domains | ||
| 442 | if archive_config | ||
| 443 | .grasp_services | ||
| 444 | .iter() | ||
| 445 | .any(|service| announcement.has_clone_url(service)) | ||
| 446 | { | ||
| 447 | return AnnouncementResult::AcceptArchive; | ||
| 448 | } | ||
| 449 | |||
| 439 | // Reject with appropriate error message | 450 | // Reject with appropriate error message |
| 440 | if archive_config.read_only { | 451 | if archive_config.read_only { |
| 441 | AnnouncementResult::Reject(format!( | 452 | AnnouncementResult::Reject(format!( |
diff --git a/tests/archive_grasp_services.rs b/tests/archive_grasp_services.rs new file mode 100644 index 0000000..a47fc55 --- /dev/null +++ b/tests/archive_grasp_services.rs | |||
| @@ -0,0 +1,378 @@ | |||
| 1 | //! Archive GRASP Services Integration Tests | ||
| 2 | //! | ||
| 3 | //! Tests that verify archive_grasp_services filtering behavior: | ||
| 4 | //! - Announcements with matching GRASP service domains are accepted | ||
| 5 | //! - Announcements with non-matching GRASP service domains are rejected | ||
| 6 | //! - Multiple configured services work correctly | ||
| 7 | //! - Case-insensitive domain matching | ||
| 8 | //! | ||
| 9 | //! # Test Strategy | ||
| 10 | //! | ||
| 11 | //! These tests verify the GRASP-05 archive mode with grasp_services filtering: | ||
| 12 | //! 1. Configure relay with specific GRASP service domains | ||
| 13 | //! 2. Send announcements with various clone URLs | ||
| 14 | //! 3. Verify announcements are accepted/rejected based on domain matching | ||
| 15 | //! 4. Verify repositories are created only for accepted announcements | ||
| 16 | //! | ||
| 17 | //! # Running Tests | ||
| 18 | //! | ||
| 19 | //! ```bash | ||
| 20 | //! # Run all archive grasp services tests | ||
| 21 | //! cargo test --test archive_grasp_services | ||
| 22 | //! | ||
| 23 | //! # Run specific test | ||
| 24 | //! cargo test --test archive_grasp_services test_archive_accepts_matching_grasp_service | ||
| 25 | //! | ||
| 26 | //! # With output for debugging | ||
| 27 | //! cargo test --test archive_grasp_services -- --nocapture | ||
| 28 | //! ``` | ||
| 29 | |||
| 30 | mod common; | ||
| 31 | |||
| 32 | use common::TestRelay; | ||
| 33 | use nostr_sdk::prelude::*; | ||
| 34 | use std::path::PathBuf; | ||
| 35 | use std::process::{Child, Command, Stdio}; | ||
| 36 | use std::time::Duration; | ||
| 37 | |||
| 38 | /// Helper to start a relay with archive_grasp_services configuration | ||
| 39 | /// | ||
| 40 | /// This is a specialized version of TestRelay::start_with_archive_and_sync | ||
| 41 | /// that adds the NGIT_ARCHIVE_GRASP_SERVICES environment variable. | ||
| 42 | async fn start_relay_with_grasp_services(services: &str) -> (Child, String, PathBuf) { | ||
| 43 | let port = TestRelay::find_free_port(); | ||
| 44 | let bind_address = format!("127.0.0.1:{}", port); | ||
| 45 | let url = format!("ws://127.0.0.1:{}", port); | ||
| 46 | |||
| 47 | // Create temporary directory for git repositories | ||
| 48 | let git_data_dir = tempfile::tempdir().expect("Failed to create temporary git data directory"); | ||
| 49 | |||
| 50 | // Use the built binary directly | ||
| 51 | let binary_path = std::env::current_exe() | ||
| 52 | .expect("Failed to get current exe") | ||
| 53 | .parent() | ||
| 54 | .expect("Failed to get parent dir") | ||
| 55 | .parent() | ||
| 56 | .expect("Failed to get grandparent dir") | ||
| 57 | .join("ngit-grasp"); | ||
| 58 | |||
| 59 | // Generate a test owner npub | ||
| 60 | let test_keys = nostr_sdk::Keys::generate(); | ||
| 61 | let test_npub = test_keys | ||
| 62 | .public_key() | ||
| 63 | .to_bech32() | ||
| 64 | .expect("Failed to generate test npub"); | ||
| 65 | |||
| 66 | // Start the relay process with archive_grasp_services | ||
| 67 | let mut cmd = Command::new(&binary_path); | ||
| 68 | cmd.env("NGIT_BIND_ADDRESS", &bind_address) | ||
| 69 | .env("NGIT_DOMAIN", &bind_address) | ||
| 70 | .env("NGIT_GIT_DATA_PATH", git_data_dir.path()) | ||
| 71 | .env("NGIT_DATABASE_BACKEND", "memory") | ||
| 72 | .env("NGIT_OWNER_NPUB", &test_npub) | ||
| 73 | .env("NGIT_ARCHIVE_GRASP_SERVICES", services) | ||
| 74 | .env( | ||
| 75 | "RUST_LOG", | ||
| 76 | std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), | ||
| 77 | ) | ||
| 78 | .stdout(Stdio::null()) | ||
| 79 | .stderr(Stdio::null()); | ||
| 80 | |||
| 81 | let process = cmd.spawn().expect("Failed to start relay process"); | ||
| 82 | |||
| 83 | // Store git data path for test assertions | ||
| 84 | let git_data_path = git_data_dir.path().to_path_buf(); | ||
| 85 | |||
| 86 | // Wait for relay to be ready | ||
| 87 | wait_for_relay_ready(port).await; | ||
| 88 | |||
| 89 | (process, url, git_data_path) | ||
| 90 | } | ||
| 91 | |||
| 92 | /// Wait for the relay to be ready to accept connections | ||
| 93 | async fn wait_for_relay_ready(port: u16) { | ||
| 94 | let max_attempts = 50; // 5 seconds total | ||
| 95 | let delay = Duration::from_millis(100); | ||
| 96 | |||
| 97 | for attempt in 0..max_attempts { | ||
| 98 | // Try to connect to the relay | ||
| 99 | match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)).await { | ||
| 100 | Ok(_) => { | ||
| 101 | // Connection successful, relay is ready | ||
| 102 | // Give it a tiny bit more time to fully initialize | ||
| 103 | tokio::time::sleep(Duration::from_millis(100)).await; | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | Err(_) => { | ||
| 107 | if attempt == max_attempts - 1 { | ||
| 108 | panic!("Relay failed to start after {} attempts", max_attempts); | ||
| 109 | } | ||
| 110 | tokio::time::sleep(delay).await; | ||
| 111 | } | ||
| 112 | } | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | /// Test that announcements with matching GRASP service domains are accepted. | ||
| 117 | /// | ||
| 118 | /// Scenario: | ||
| 119 | /// 1. Start relay with archive_grasp_services="git.example.com" | ||
| 120 | /// 2. Send announcement with clone URL from git.example.com | ||
| 121 | /// 3. Verify announcement is accepted (repository is created) | ||
| 122 | #[tokio::test] | ||
| 123 | async fn test_archive_accepts_matching_grasp_service() { | ||
| 124 | let (mut process, url, git_data_path) = | ||
| 125 | start_relay_with_grasp_services("git.example.com").await; | ||
| 126 | let keys = Keys::generate(); | ||
| 127 | let identifier = "test-repo"; | ||
| 128 | |||
| 129 | // Create announcement with clone URL from git.example.com | ||
| 130 | let npub = keys.public_key().to_bech32().expect("Failed to get npub"); | ||
| 131 | let tags = vec![ | ||
| 132 | Tag::identifier(identifier), | ||
| 133 | Tag::custom( | ||
| 134 | TagKind::custom("clone"), | ||
| 135 | vec![format!("https://git.example.com/user/{}.git", identifier)], | ||
| 136 | ), | ||
| 137 | Tag::custom( | ||
| 138 | TagKind::custom("relays"), | ||
| 139 | vec!["wss://relay.example.com".to_string()], | ||
| 140 | ), | ||
| 141 | ]; | ||
| 142 | |||
| 143 | let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") | ||
| 144 | .tags(tags) | ||
| 145 | .sign_with_keys(&keys) | ||
| 146 | .expect("Failed to sign announcement"); | ||
| 147 | |||
| 148 | // Send announcement to relay | ||
| 149 | let client = Client::new(keys.clone()); | ||
| 150 | client.add_relay(&url).await.expect("Failed to add relay"); | ||
| 151 | client.connect().await; | ||
| 152 | |||
| 153 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 154 | |||
| 155 | client | ||
| 156 | .send_event(&announcement) | ||
| 157 | .await | ||
| 158 | .expect("Failed to send announcement"); | ||
| 159 | |||
| 160 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 161 | |||
| 162 | // Verify repository was created (announcement was accepted) | ||
| 163 | let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier)); | ||
| 164 | |||
| 165 | assert!( | ||
| 166 | repo_path.exists(), | ||
| 167 | "Repository should be created for announcement with matching GRASP service domain" | ||
| 168 | ); | ||
| 169 | |||
| 170 | // Cleanup | ||
| 171 | client.disconnect().await; | ||
| 172 | let _ = process.kill(); | ||
| 173 | let _ = process.wait(); | ||
| 174 | } | ||
| 175 | |||
| 176 | /// Test that announcements with non-matching GRASP service domains are rejected. | ||
| 177 | /// | ||
| 178 | /// Scenario: | ||
| 179 | /// 1. Start relay with archive_grasp_services="git.example.com" | ||
| 180 | /// 2. Send announcement with clone URL from github.com (not in services list) | ||
| 181 | /// 3. Verify announcement is rejected (repository is NOT created) | ||
| 182 | #[tokio::test] | ||
| 183 | async fn test_archive_rejects_non_matching_grasp_service() { | ||
| 184 | let (mut process, url, git_data_path) = | ||
| 185 | start_relay_with_grasp_services("git.example.com").await; | ||
| 186 | let keys = Keys::generate(); | ||
| 187 | let identifier = "test-repo"; | ||
| 188 | |||
| 189 | // Create announcement with clone URL from github.com (NOT in services list) | ||
| 190 | let npub = keys.public_key().to_bech32().expect("Failed to get npub"); | ||
| 191 | let tags = vec![ | ||
| 192 | Tag::identifier(identifier), | ||
| 193 | Tag::custom( | ||
| 194 | TagKind::custom("clone"), | ||
| 195 | vec![format!("https://github.com/user/{}.git", identifier)], | ||
| 196 | ), | ||
| 197 | Tag::custom( | ||
| 198 | TagKind::custom("relays"), | ||
| 199 | vec!["wss://relay.example.com".to_string()], | ||
| 200 | ), | ||
| 201 | ]; | ||
| 202 | |||
| 203 | let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") | ||
| 204 | .tags(tags) | ||
| 205 | .sign_with_keys(&keys) | ||
| 206 | .expect("Failed to sign announcement"); | ||
| 207 | |||
| 208 | // Send announcement to relay | ||
| 209 | let client = Client::new(keys.clone()); | ||
| 210 | client.add_relay(&url).await.expect("Failed to add relay"); | ||
| 211 | client.connect().await; | ||
| 212 | |||
| 213 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 214 | |||
| 215 | client | ||
| 216 | .send_event(&announcement) | ||
| 217 | .await | ||
| 218 | .expect("Failed to send announcement"); | ||
| 219 | |||
| 220 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 221 | |||
| 222 | // Verify repository was NOT created (announcement was rejected) | ||
| 223 | let repo_path = git_data_path.join(format!("{}/{}.git", npub, identifier)); | ||
| 224 | |||
| 225 | assert!( | ||
| 226 | !repo_path.exists(), | ||
| 227 | "Repository should NOT be created for announcement with non-matching GRASP service domain" | ||
| 228 | ); | ||
| 229 | |||
| 230 | // Cleanup | ||
| 231 | client.disconnect().await; | ||
| 232 | let _ = process.kill(); | ||
| 233 | let _ = process.wait(); | ||
| 234 | } | ||
| 235 | |||
| 236 | /// Test that multiple configured GRASP services work correctly. | ||
| 237 | /// | ||
| 238 | /// Scenario: | ||
| 239 | /// 1. Start relay with archive_grasp_services="git.example.com,gitlab.example.org" | ||
| 240 | /// 2. Send announcements with clone URLs from both services | ||
| 241 | /// 3. Verify both announcements are accepted | ||
| 242 | /// 4. Send announcement from non-listed service | ||
| 243 | /// 5. Verify it is rejected | ||
| 244 | #[tokio::test] | ||
| 245 | async fn test_archive_multiple_grasp_services() { | ||
| 246 | let (mut process, url, git_data_path) = | ||
| 247 | start_relay_with_grasp_services("git.example.com,gitlab.example.org").await; | ||
| 248 | |||
| 249 | // Test first service (git.example.com) | ||
| 250 | let keys1 = Keys::generate(); | ||
| 251 | let identifier1 = "test-repo-1"; | ||
| 252 | let npub1 = keys1.public_key().to_bech32().expect("Failed to get npub"); | ||
| 253 | |||
| 254 | let tags1 = vec![ | ||
| 255 | Tag::identifier(identifier1), | ||
| 256 | Tag::custom( | ||
| 257 | TagKind::custom("clone"), | ||
| 258 | vec![format!("https://git.example.com/user/{}.git", identifier1)], | ||
| 259 | ), | ||
| 260 | Tag::custom( | ||
| 261 | TagKind::custom("relays"), | ||
| 262 | vec!["wss://relay.example.com".to_string()], | ||
| 263 | ), | ||
| 264 | ]; | ||
| 265 | |||
| 266 | let announcement1 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") | ||
| 267 | .tags(tags1) | ||
| 268 | .sign_with_keys(&keys1) | ||
| 269 | .expect("Failed to sign announcement"); | ||
| 270 | |||
| 271 | let client1 = Client::new(keys1.clone()); | ||
| 272 | client1.add_relay(&url).await.expect("Failed to add relay"); | ||
| 273 | client1.connect().await; | ||
| 274 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 275 | |||
| 276 | client1 | ||
| 277 | .send_event(&announcement1) | ||
| 278 | .await | ||
| 279 | .expect("Failed to send announcement"); | ||
| 280 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 281 | |||
| 282 | // Test second service (gitlab.example.org) | ||
| 283 | let keys2 = Keys::generate(); | ||
| 284 | let identifier2 = "test-repo-2"; | ||
| 285 | let npub2 = keys2.public_key().to_bech32().expect("Failed to get npub"); | ||
| 286 | |||
| 287 | let tags2 = vec![ | ||
| 288 | Tag::identifier(identifier2), | ||
| 289 | Tag::custom( | ||
| 290 | TagKind::custom("clone"), | ||
| 291 | vec![format!( | ||
| 292 | "https://gitlab.example.org/user/{}.git", | ||
| 293 | identifier2 | ||
| 294 | )], | ||
| 295 | ), | ||
| 296 | Tag::custom( | ||
| 297 | TagKind::custom("relays"), | ||
| 298 | vec!["wss://relay.example.com".to_string()], | ||
| 299 | ), | ||
| 300 | ]; | ||
| 301 | |||
| 302 | let announcement2 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") | ||
| 303 | .tags(tags2) | ||
| 304 | .sign_with_keys(&keys2) | ||
| 305 | .expect("Failed to sign announcement"); | ||
| 306 | |||
| 307 | let client2 = Client::new(keys2.clone()); | ||
| 308 | client2.add_relay(&url).await.expect("Failed to add relay"); | ||
| 309 | client2.connect().await; | ||
| 310 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 311 | |||
| 312 | client2 | ||
| 313 | .send_event(&announcement2) | ||
| 314 | .await | ||
| 315 | .expect("Failed to send announcement"); | ||
| 316 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 317 | |||
| 318 | // Test non-listed service (github.com) | ||
| 319 | let keys3 = Keys::generate(); | ||
| 320 | let identifier3 = "test-repo-3"; | ||
| 321 | let npub3 = keys3.public_key().to_bech32().expect("Failed to get npub"); | ||
| 322 | |||
| 323 | let tags3 = vec![ | ||
| 324 | Tag::identifier(identifier3), | ||
| 325 | Tag::custom( | ||
| 326 | TagKind::custom("clone"), | ||
| 327 | vec![format!("https://github.com/user/{}.git", identifier3)], | ||
| 328 | ), | ||
| 329 | Tag::custom( | ||
| 330 | TagKind::custom("relays"), | ||
| 331 | vec!["wss://relay.example.com".to_string()], | ||
| 332 | ), | ||
| 333 | ]; | ||
| 334 | |||
| 335 | let announcement3 = EventBuilder::new(Kind::GitRepoAnnouncement, "Repository state") | ||
| 336 | .tags(tags3) | ||
| 337 | .sign_with_keys(&keys3) | ||
| 338 | .expect("Failed to sign announcement"); | ||
| 339 | |||
| 340 | let client3 = Client::new(keys3.clone()); | ||
| 341 | client3.add_relay(&url).await.expect("Failed to add relay"); | ||
| 342 | client3.connect().await; | ||
| 343 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 344 | |||
| 345 | client3 | ||
| 346 | .send_event(&announcement3) | ||
| 347 | .await | ||
| 348 | .expect("Failed to send announcement"); | ||
| 349 | tokio::time::sleep(Duration::from_millis(500)).await; | ||
| 350 | |||
| 351 | // Verify first service announcement was accepted | ||
| 352 | let repo_path1 = git_data_path.join(format!("{}/{}.git", npub1, identifier1)); | ||
| 353 | assert!( | ||
| 354 | repo_path1.exists(), | ||
| 355 | "Repository should be created for first GRASP service (git.example.com)" | ||
| 356 | ); | ||
| 357 | |||
| 358 | // Verify second service announcement was accepted | ||
| 359 | let repo_path2 = git_data_path.join(format!("{}/{}.git", npub2, identifier2)); | ||
| 360 | assert!( | ||
| 361 | repo_path2.exists(), | ||
| 362 | "Repository should be created for second GRASP service (gitlab.example.org)" | ||
| 363 | ); | ||
| 364 | |||
| 365 | // Verify non-listed service announcement was rejected | ||
| 366 | let repo_path3 = git_data_path.join(format!("{}/{}.git", npub3, identifier3)); | ||
| 367 | assert!( | ||
| 368 | !repo_path3.exists(), | ||
| 369 | "Repository should NOT be created for non-listed service (github.com)" | ||
| 370 | ); | ||
| 371 | |||
| 372 | // Cleanup | ||
| 373 | client1.disconnect().await; | ||
| 374 | client2.disconnect().await; | ||
| 375 | client3.disconnect().await; | ||
| 376 | let _ = process.kill(); | ||
| 377 | let _ = process.wait(); | ||
| 378 | } | ||