diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 11:56:49 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 11:58:34 +0000 |
| commit | 7a78815e29b01c83f3d0ec195ba717a2eba8cd37 (patch) | |
| tree | 4c5ccd9b812f1d1d75ed218501192ddc5459fd12 | |
| parent | e6ceab90de1acad154624022a6036efac18abab6 (diff) | |
reject push when refs/nostr/<event-id> doesnt match known event and delete incorrect ref on event receive
| -rw-r--r-- | grasp-audit/src/client.rs | 23 | ||||
| -rw-r--r-- | grasp-audit/src/fixtures.rs | 172 | ||||
| -rw-r--r-- | grasp-audit/src/specs/grasp01/push_authorization.rs | 708 | ||||
| -rw-r--r-- | src/git/authorization.rs | 142 | ||||
| -rw-r--r-- | src/git/handlers.rs | 211 | ||||
| -rw-r--r-- | src/git/mod.rs | 210 | ||||
| -rw-r--r-- | src/nostr/builder.rs | 389 | ||||
| -rw-r--r-- | src/nostr/events.rs | 6 |
8 files changed, 1373 insertions, 488 deletions
diff --git a/grasp-audit/src/client.rs b/grasp-audit/src/client.rs index ed76a34..e4e9f07 100644 --- a/grasp-audit/src/client.rs +++ b/grasp-audit/src/client.rs | |||
| @@ -129,7 +129,9 @@ impl AuditClient { | |||
| 129 | /// Get the relay URL | 129 | /// Get the relay URL |
| 130 | pub async fn relay_url(&self) -> Result<String> { | 130 | pub async fn relay_url(&self) -> Result<String> { |
| 131 | let relays = self.client.relays().await; | 131 | let relays = self.client.relays().await; |
| 132 | let relay = relays.values().next() | 132 | let relay = relays |
| 133 | .values() | ||
| 134 | .next() | ||
| 133 | .ok_or_else(|| anyhow!("No relays configured"))?; | 135 | .ok_or_else(|| anyhow!("No relays configured"))?; |
| 134 | Ok(relay.url().to_string()) | 136 | Ok(relay.url().to_string()) |
| 135 | } | 137 | } |
| @@ -522,12 +524,14 @@ mod tests { | |||
| 522 | let keys = Keys::generate(); | 524 | let keys = Keys::generate(); |
| 523 | let maintainer_keys = Keys::generate(); | 525 | let maintainer_keys = Keys::generate(); |
| 524 | let recursive_maintainer_keys = Keys::generate(); | 526 | let recursive_maintainer_keys = Keys::generate(); |
| 527 | let pr_author_keys = Keys::generate(); | ||
| 525 | let client = AuditClient { | 528 | let client = AuditClient { |
| 526 | client: Client::new(keys.clone()), | 529 | client: Client::new(keys.clone()), |
| 527 | config: config.clone(), | 530 | config: config.clone(), |
| 528 | keys: keys.clone(), | 531 | keys: keys.clone(), |
| 529 | maintainer_keys, | 532 | maintainer_keys, |
| 530 | recursive_maintainer_keys, | 533 | recursive_maintainer_keys, |
| 534 | pr_author_keys, | ||
| 531 | fixture_cache: Arc::new(Mutex::new(HashMap::new())), | 535 | fixture_cache: Arc::new(Mutex::new(HashMap::new())), |
| 532 | }; | 536 | }; |
| 533 | 537 | ||
| @@ -543,12 +547,14 @@ mod tests { | |||
| 543 | let keys = Keys::generate(); | 547 | let keys = Keys::generate(); |
| 544 | let maintainer_keys = Keys::generate(); | 548 | let maintainer_keys = Keys::generate(); |
| 545 | let recursive_maintainer_keys = Keys::generate(); | 549 | let recursive_maintainer_keys = Keys::generate(); |
| 550 | let pr_author_keys = Keys::generate(); | ||
| 546 | let client = AuditClient { | 551 | let client = AuditClient { |
| 547 | client: Client::new(keys.clone()), | 552 | client: Client::new(keys.clone()), |
| 548 | config: config.clone(), | 553 | config: config.clone(), |
| 549 | keys: keys.clone(), | 554 | keys: keys.clone(), |
| 550 | maintainer_keys, | 555 | maintainer_keys, |
| 551 | recursive_maintainer_keys, | 556 | recursive_maintainer_keys, |
| 557 | pr_author_keys, | ||
| 552 | fixture_cache: Arc::new(Mutex::new(HashMap::new())), | 558 | fixture_cache: Arc::new(Mutex::new(HashMap::new())), |
| 553 | }; | 559 | }; |
| 554 | 560 | ||
| @@ -610,13 +616,10 @@ mod tests { | |||
| 610 | // Note: We can't test create_repo_announcement_with_maintainers directly in unit tests | 616 | // Note: We can't test create_repo_announcement_with_maintainers directly in unit tests |
| 611 | // because it requires a connected relay. Instead, we test the underlying event building | 617 | // because it requires a connected relay. Instead, we test the underlying event building |
| 612 | // with maintainers tag to verify the tag format is correct. | 618 | // with maintainers tag to verify the tag format is correct. |
| 613 | 619 | ||
| 614 | // Build an event with maintainers tag directly to test the tag format | 620 | // Build an event with maintainers tag directly to test the tag format |
| 615 | let event = client | 621 | let event = client |
| 616 | .event_builder( | 622 | .event_builder(Kind::GitRepoAnnouncement, "Test repository") |
| 617 | Kind::GitRepoAnnouncement, | ||
| 618 | "Test repository", | ||
| 619 | ) | ||
| 620 | .tag(Tag::identifier("test-repo")) | 623 | .tag(Tag::identifier("test-repo")) |
| 621 | .tag(Tag::custom( | 624 | .tag(Tag::custom( |
| 622 | TagKind::custom("maintainers"), | 625 | TagKind::custom("maintainers"), |
| @@ -639,10 +642,14 @@ mod tests { | |||
| 639 | // Verify the tag contains the maintainer pubkeys | 642 | // Verify the tag contains the maintainer pubkeys |
| 640 | let tag = maintainers_tag.unwrap(); | 643 | let tag = maintainers_tag.unwrap(); |
| 641 | let tag_vec: Vec<String> = tag.clone().to_vec(); | 644 | let tag_vec: Vec<String> = tag.clone().to_vec(); |
| 642 | 645 | ||
| 643 | // First element is "maintainers", rest are the pubkeys | 646 | // First element is "maintainers", rest are the pubkeys |
| 644 | assert_eq!(tag_vec[0], "maintainers"); | 647 | assert_eq!(tag_vec[0], "maintainers"); |
| 645 | assert_eq!(tag_vec.len(), 3, "Expected 3 elements: tag name + 2 pubkeys"); | 648 | assert_eq!( |
| 649 | tag_vec.len(), | ||
| 650 | 3, | ||
| 651 | "Expected 3 elements: tag name + 2 pubkeys" | ||
| 652 | ); | ||
| 646 | assert_eq!(tag_vec[1], maintainer_pubkeys[0]); | 653 | assert_eq!(tag_vec[1], maintainer_pubkeys[0]); |
| 647 | assert_eq!(tag_vec[2], maintainer_pubkeys[1]); | 654 | assert_eq!(tag_vec[2], maintainer_pubkeys[1]); |
| 648 | } | 655 | } |
diff --git a/grasp-audit/src/fixtures.rs b/grasp-audit/src/fixtures.rs index dc4e638..4b5014d 100644 --- a/grasp-audit/src/fixtures.rs +++ b/grasp-audit/src/fixtures.rs | |||
| @@ -59,7 +59,7 @@ pub const DETERMINISTIC_COMMIT_HASH: &str = "64ea71d79a57a7acb334cd9651f8aec067c | |||
| 59 | /// - GPG signing: disabled | 59 | /// - GPG signing: disabled |
| 60 | /// - User: "GRASP Audit Test <test@grasp-audit.local>" | 60 | /// - User: "GRASP Audit Test <test@grasp-audit.local>" |
| 61 | /// - Parent: none (root commit) | 61 | /// - Parent: none (root commit) |
| 62 | /// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content | 62 | /// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content |
| 63 | pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464"; | 63 | pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a66500281a3c4a6840464"; |
| 64 | 64 | ||
| 65 | /// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant) | 65 | /// Deterministic commit hash for recursive maintainer fixtures (RecursiveMaintainer variant) |
| @@ -71,8 +71,9 @@ pub const MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "1c2d472c9b71ed51968a6650 | |||
| 71 | /// - GPG signing: disabled | 71 | /// - GPG signing: disabled |
| 72 | /// - User: "GRASP Audit Test <test@grasp-audit.local>" | 72 | /// - User: "GRASP Audit Test <test@grasp-audit.local>" |
| 73 | /// - Parent: none (root commit) | 73 | /// - Parent: none (root commit) |
| 74 | /// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content | 74 | /// NOTE: This value is different from DETERMINISTIC_COMMIT_HASH due to different content |
| 75 | pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fbdb9c077d0a64fc68522f3cb8e0"; | 75 | pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = |
| 76 | "05939b82de66fbdb9c077d0a64fc68522f3cb8e0"; | ||
| 76 | 77 | ||
| 77 | /// Deterministic commit hash for PR test fixtures (PRTestCommit variant) | 78 | /// Deterministic commit hash for PR test fixtures (PRTestCommit variant) |
| 78 | /// This is the hash produced by creating a commit with: | 79 | /// This is the hash produced by creating a commit with: |
| @@ -83,7 +84,7 @@ pub const RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH: &str = "05939b82de66fb | |||
| 83 | /// - GPG signing: disabled | 84 | /// - GPG signing: disabled |
| 84 | /// - User: "GRASP Audit Test <test@grasp-audit.local>" | 85 | /// - User: "GRASP Audit Test <test@grasp-audit.local>" |
| 85 | /// - Parent: none (root commit) | 86 | /// - Parent: none (root commit) |
| 86 | pub const PR_TEST_COMMIT_HASH: &str = "8935183ff722bf04e861928c6a7e50868c6ca4a6"; | 87 | pub const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb"; |
| 87 | 88 | ||
| 88 | /// Types of test fixtures available | 89 | /// Types of test fixtures available |
| 89 | /// | 90 | /// |
| @@ -157,10 +158,10 @@ pub enum FixtureKind { | |||
| 157 | /// - Timestamp: 2 seconds in the past (most recent) | 158 | /// - Timestamp: 2 seconds in the past (most recent) |
| 158 | RecursiveMaintainerRepoAndState, | 159 | RecursiveMaintainerRepoAndState, |
| 159 | 160 | ||
| 160 | /// PR (Patch/Pull Request) event for the SAME repo_id as ValidRepo | 161 | /// PR (Pull Request) event for the SAME repo_id as ValidRepo |
| 161 | /// - Requires ValidRepo (uses same repo_id) | 162 | /// - Requires ValidRepo (uses same repo_id) |
| 162 | /// - Signed by `client.pr_author_keys()` | 163 | /// - Signed by `client.pr_author_keys()` |
| 163 | /// - Kind 1617 (NIP-34 patch) | 164 | /// - Kind 1618 (NIP-34 PR) |
| 164 | /// - Includes `a` tag referencing the repo | 165 | /// - Includes `a` tag referencing the repo |
| 165 | /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH | 166 | /// - Includes `c` tag pointing to PR_TEST_COMMIT_HASH |
| 166 | /// - Timestamp: 1 second in the past | 167 | /// - Timestamp: 1 second in the past |
| @@ -290,6 +291,37 @@ impl<'a> TestContext<'a> { | |||
| 290 | self.mode | 291 | self.mode |
| 291 | } | 292 | } |
| 292 | 293 | ||
| 294 | /// Build a fixture event WITHOUT publishing it to the relay. | ||
| 295 | /// | ||
| 296 | /// This is useful for tests that need to get a fixture's event ID before | ||
| 297 | /// actually publishing it. For example, testing refs/nostr/<event-id> | ||
| 298 | /// behavior before the corresponding event exists on the relay. | ||
| 299 | /// | ||
| 300 | /// Note: This may still create and publish dependencies (e.g., ValidRepo | ||
| 301 | /// will be created/published if PREvent needs it), but the requested | ||
| 302 | /// fixture itself will NOT be published. | ||
| 303 | /// | ||
| 304 | /// # Example | ||
| 305 | /// | ||
| 306 | /// ```no_run | ||
| 307 | /// # use grasp_audit::*; | ||
| 308 | /// # async fn example(ctx: &TestContext<'_>) -> anyhow::Result<()> { | ||
| 309 | /// // Build PR event to get its ID without publishing | ||
| 310 | /// let pr_event = ctx.build_fixture_only(FixtureKind::PREvent).await?; | ||
| 311 | /// let pr_event_id = pr_event.id.to_hex(); | ||
| 312 | /// | ||
| 313 | /// // Now push to refs/nostr/<pr_event_id> before event exists | ||
| 314 | /// // ... git push ... | ||
| 315 | /// | ||
| 316 | /// // Later, publish the PR event when ready | ||
| 317 | /// ctx.client().send_event(pr_event).await?; | ||
| 318 | /// # Ok(()) | ||
| 319 | /// # } | ||
| 320 | /// ``` | ||
| 321 | pub async fn build_fixture_only(&self, kind: FixtureKind) -> Result<Event> { | ||
| 322 | self.build_fixture(kind).await | ||
| 323 | } | ||
| 324 | |||
| 293 | /// Create a fresh fixture (always creates new) | 325 | /// Create a fresh fixture (always creates new) |
| 294 | async fn create_fresh(&self, kind: FixtureKind) -> Result<Event> { | 326 | async fn create_fresh(&self, kind: FixtureKind) -> Result<Event> { |
| 295 | let event = self | 327 | let event = self |
| @@ -348,7 +380,11 @@ impl<'a> TestContext<'a> { | |||
| 348 | { | 380 | { |
| 349 | let mut cache = self.client.fixture_cache().lock().unwrap(); | 381 | let mut cache = self.client.fixture_cache().lock().unwrap(); |
| 350 | cache.insert(kind, event.clone()); | 382 | cache.insert(kind, event.clone()); |
| 351 | tracing::debug!("get_or_create_shared({:?}) stored in client cache ({} entries)", kind, cache.len()); | 383 | tracing::debug!( |
| 384 | "get_or_create_shared({:?}) stored in client cache ({} entries)", | ||
| 385 | kind, | ||
| 386 | cache.len() | ||
| 387 | ); | ||
| 352 | } | 388 | } |
| 353 | 389 | ||
| 354 | Ok(event) | 390 | Ok(event) |
| @@ -398,12 +434,17 @@ impl<'a> TestContext<'a> { | |||
| 398 | FixtureKind::ValidRepo, | 434 | FixtureKind::ValidRepo, |
| 399 | &uuid::Uuid::new_v4().to_string()[..8] | 435 | &uuid::Uuid::new_v4().to_string()[..8] |
| 400 | ); | 436 | ); |
| 401 | 437 | ||
| 402 | let repo = self.client.create_repo_announcement(&test_name).await | 438 | let repo = self |
| 439 | .client | ||
| 440 | .create_repo_announcement(&test_name) | ||
| 441 | .await | ||
| 403 | .with_context(|| format!("create_repo_announcement failed for {}", test_name))?; | 442 | .with_context(|| format!("create_repo_announcement failed for {}", test_name))?; |
| 404 | 443 | ||
| 405 | // Send it | 444 | // Send it |
| 406 | self.client.send_event(repo.clone()).await | 445 | self.client |
| 446 | .send_event(repo.clone()) | ||
| 447 | .await | ||
| 407 | .with_context(|| "Failed to send repo announcement to relay")?; | 448 | .with_context(|| "Failed to send repo announcement to relay")?; |
| 408 | 449 | ||
| 409 | // Store in the appropriate cache based on mode | 450 | // Store in the appropriate cache based on mode |
| @@ -412,13 +453,19 @@ impl<'a> TestContext<'a> { | |||
| 412 | // Store in local cache for within-test fixture dependencies | 453 | // Store in local cache for within-test fixture dependencies |
| 413 | let mut cache = self.local_cache.lock().unwrap(); | 454 | let mut cache = self.local_cache.lock().unwrap(); |
| 414 | cache.insert(FixtureKind::ValidRepo, repo.clone()); | 455 | cache.insert(FixtureKind::ValidRepo, repo.clone()); |
| 415 | tracing::debug!("get_or_create_repo() stored in local cache ({} entries)", cache.len()); | 456 | tracing::debug!( |
| 457 | "get_or_create_repo() stored in local cache ({} entries)", | ||
| 458 | cache.len() | ||
| 459 | ); | ||
| 416 | } | 460 | } |
| 417 | ContextMode::Shared => { | 461 | ContextMode::Shared => { |
| 418 | // Store in client cache for cross-test sharing | 462 | // Store in client cache for cross-test sharing |
| 419 | let mut cache = self.client.fixture_cache().lock().unwrap(); | 463 | let mut cache = self.client.fixture_cache().lock().unwrap(); |
| 420 | cache.insert(FixtureKind::ValidRepo, repo.clone()); | 464 | cache.insert(FixtureKind::ValidRepo, repo.clone()); |
| 421 | tracing::debug!("get_or_create_repo() stored in client cache ({} entries)", cache.len()); | 465 | tracing::debug!( |
| 466 | "get_or_create_repo() stored in client cache ({} entries)", | ||
| 467 | cache.len() | ||
| 468 | ); | ||
| 422 | } | 469 | } |
| 423 | } | 470 | } |
| 424 | 471 | ||
| @@ -448,12 +495,9 @@ impl<'a> TestContext<'a> { | |||
| 448 | let repo = self.get_or_create_repo().await?; | 495 | let repo = self.get_or_create_repo().await?; |
| 449 | 496 | ||
| 450 | // Create the issue | 497 | // Create the issue |
| 451 | let issue = self.client.create_issue( | 498 | let issue = |
| 452 | &repo, | 499 | self.client |
| 453 | "Test Issue", | 500 | .create_issue(&repo, "Test Issue", "Issue content for testing", vec![])?; |
| 454 | "Issue content for testing", | ||
| 455 | vec![], | ||
| 456 | )?; | ||
| 457 | 501 | ||
| 458 | // Send it | 502 | // Send it |
| 459 | self.client.send_event(issue.clone()).await?; | 503 | self.client.send_event(issue.clone()).await?; |
| @@ -555,7 +599,7 @@ impl<'a> TestContext<'a> { | |||
| 555 | 599 | ||
| 556 | // Get the owner's repo to use the SAME repo_id | 600 | // Get the owner's repo to use the SAME repo_id |
| 557 | let owner_repo = self.get_or_create_repo().await?; | 601 | let owner_repo = self.get_or_create_repo().await?; |
| 558 | 602 | ||
| 559 | // Extract repo_id from owner's repo announcement | 603 | // Extract repo_id from owner's repo announcement |
| 560 | let repo_id = owner_repo | 604 | let repo_id = owner_repo |
| 561 | .tags | 605 | .tags |
| @@ -573,7 +617,7 @@ impl<'a> TestContext<'a> { | |||
| 573 | 617 | ||
| 574 | // Get the owner's repo to use the SAME repo_id | 618 | // Get the owner's repo to use the SAME repo_id |
| 575 | let owner_repo = self.get_or_create_repo().await?; | 619 | let owner_repo = self.get_or_create_repo().await?; |
| 576 | 620 | ||
| 577 | // Extract repo_id from owner's repo announcement | 621 | // Extract repo_id from owner's repo announcement |
| 578 | let repo_id = owner_repo | 622 | let repo_id = owner_repo |
| 579 | .tags | 623 | .tags |
| @@ -593,7 +637,7 @@ impl<'a> TestContext<'a> { | |||
| 593 | 637 | ||
| 594 | // Get the owner's repo to use the SAME repo_id | 638 | // Get the owner's repo to use the SAME repo_id |
| 595 | let owner_repo = self.get_or_create_repo().await?; | 639 | let owner_repo = self.get_or_create_repo().await?; |
| 596 | 640 | ||
| 597 | // Extract repo_id from owner's repo announcement | 641 | // Extract repo_id from owner's repo announcement |
| 598 | let repo_id = owner_repo | 642 | let repo_id = owner_repo |
| 599 | .tags | 643 | .tags |
| @@ -611,7 +655,7 @@ impl<'a> TestContext<'a> { | |||
| 611 | 655 | ||
| 612 | // Get the owner's repo to use the SAME repo_id | 656 | // Get the owner's repo to use the SAME repo_id |
| 613 | let owner_repo = self.get_or_create_repo().await?; | 657 | let owner_repo = self.get_or_create_repo().await?; |
| 614 | 658 | ||
| 615 | // Extract repo_id from owner's repo announcement | 659 | // Extract repo_id from owner's repo announcement |
| 616 | let repo_id = owner_repo | 660 | let repo_id = owner_repo |
| 617 | .tags | 661 | .tags |
| @@ -630,7 +674,7 @@ impl<'a> TestContext<'a> { | |||
| 630 | 674 | ||
| 631 | // Get the owner's repo to use the SAME repo_id | 675 | // Get the owner's repo to use the SAME repo_id |
| 632 | let owner_repo = self.get_or_create_repo().await?; | 676 | let owner_repo = self.get_or_create_repo().await?; |
| 633 | 677 | ||
| 634 | // Extract repo_id from owner's repo announcement | 678 | // Extract repo_id from owner's repo announcement |
| 635 | let repo_id = owner_repo | 679 | let repo_id = owner_repo |
| 636 | .tags | 680 | .tags |
| @@ -647,8 +691,12 @@ impl<'a> TestContext<'a> { | |||
| 647 | self.client.send_event(maintainer_announcement).await?; | 691 | self.client.send_event(maintainer_announcement).await?; |
| 648 | 692 | ||
| 649 | // Build and send the recursive maintainer's repo announcement | 693 | // Build and send the recursive maintainer's repo announcement |
| 650 | let recursive_maintainer_announcement = self.build_recursive_maintainer_announcement(&repo_id).await?; | 694 | let recursive_maintainer_announcement = self |
| 651 | self.client.send_event(recursive_maintainer_announcement).await?; | 695 | .build_recursive_maintainer_announcement(&repo_id) |
| 696 | .await?; | ||
| 697 | self.client | ||
| 698 | .send_event(recursive_maintainer_announcement) | ||
| 699 | .await?; | ||
| 652 | 700 | ||
| 653 | // Return the state event (caller will send it) | 701 | // Return the state event (caller will send it) |
| 654 | self.build_recursive_maintainer_state(&repo_id) | 702 | self.build_recursive_maintainer_state(&repo_id) |
| @@ -672,10 +720,10 @@ impl<'a> TestContext<'a> { | |||
| 672 | let base_time = Timestamp::now().as_u64(); | 720 | let base_time = Timestamp::now().as_u64(); |
| 673 | let pr_timestamp = Timestamp::from(base_time - 1); | 721 | let pr_timestamp = Timestamp::from(base_time - 1); |
| 674 | 722 | ||
| 675 | // Build NIP-34 patch event (kind 1617) | 723 | // Build NIP-34 PR event (kind 1618) |
| 676 | self.client | 724 | self.client |
| 677 | .event_builder( | 725 | .event_builder( |
| 678 | Kind::Custom(1617), // NIP-34 patch/PR kind | 726 | Kind::Custom(1618), // NIP-34 PR kind (has 'c' tag for commit) |
| 679 | "Test PR for GRASP validation", | 727 | "Test PR for GRASP validation", |
| 680 | ) | 728 | ) |
| 681 | .tag(Tag::custom( | 729 | .tag(Tag::custom( |
| @@ -702,7 +750,8 @@ impl<'a> TestContext<'a> { | |||
| 702 | use nostr_sdk::prelude::*; | 750 | use nostr_sdk::prelude::*; |
| 703 | 751 | ||
| 704 | // Get relay URL for clone tag | 752 | // Get relay URL for clone tag |
| 705 | let relay_url = self.client | 753 | let relay_url = self |
| 754 | .client | ||
| 706 | .client() | 755 | .client() |
| 707 | .relays() | 756 | .relays() |
| 708 | .await | 757 | .await |
| @@ -715,7 +764,8 @@ impl<'a> TestContext<'a> { | |||
| 715 | .replace("wss://", "https://"); | 764 | .replace("wss://", "https://"); |
| 716 | 765 | ||
| 717 | // Create maintainer's repo announcement for the SAME repo_id | 766 | // Create maintainer's repo announcement for the SAME repo_id |
| 718 | let maintainer_npub = self.client | 767 | let maintainer_npub = self |
| 768 | .client | ||
| 719 | .maintainer_keys() | 769 | .maintainer_keys() |
| 720 | .public_key() | 770 | .public_key() |
| 721 | .to_bech32() | 771 | .to_bech32() |
| @@ -735,10 +785,7 @@ impl<'a> TestContext<'a> { | |||
| 735 | TagKind::custom("clone"), | 785 | TagKind::custom("clone"), |
| 736 | vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], | 786 | vec![format!("{}/{}/{}.git", http_url, maintainer_npub, repo_id)], |
| 737 | )) | 787 | )) |
| 738 | .tag(Tag::custom( | 788 | .tag(Tag::custom(TagKind::custom("relays"), vec![relay_url])) |
| 739 | TagKind::custom("relays"), | ||
| 740 | vec![relay_url], | ||
| 741 | )) | ||
| 742 | .tag(Tag::custom( | 789 | .tag(Tag::custom( |
| 743 | TagKind::custom("maintainers"), | 790 | TagKind::custom("maintainers"), |
| 744 | vec![self.client.recursive_maintainer_pubkey_hex()], | 791 | vec![self.client.recursive_maintainer_pubkey_hex()], |
| @@ -776,7 +823,8 @@ impl<'a> TestContext<'a> { | |||
| 776 | use nostr_sdk::prelude::*; | 823 | use nostr_sdk::prelude::*; |
| 777 | 824 | ||
| 778 | // Get relay URL for clone tag | 825 | // Get relay URL for clone tag |
| 779 | let relay_url = self.client | 826 | let relay_url = self |
| 827 | .client | ||
| 780 | .client() | 828 | .client() |
| 781 | .relays() | 829 | .relays() |
| 782 | .await | 830 | .await |
| @@ -789,7 +837,8 @@ impl<'a> TestContext<'a> { | |||
| 789 | .replace("wss://", "https://"); | 837 | .replace("wss://", "https://"); |
| 790 | 838 | ||
| 791 | // Create recursive maintainer's repo announcement for the SAME repo_id | 839 | // Create recursive maintainer's repo announcement for the SAME repo_id |
| 792 | let recursive_maintainer_npub = self.client | 840 | let recursive_maintainer_npub = self |
| 841 | .client | ||
| 793 | .recursive_maintainer_keys() | 842 | .recursive_maintainer_keys() |
| 794 | .public_key() | 843 | .public_key() |
| 795 | .to_bech32() | 844 | .to_bech32() |
| @@ -807,12 +856,12 @@ impl<'a> TestContext<'a> { | |||
| 807 | )) | 856 | )) |
| 808 | .tag(Tag::custom( | 857 | .tag(Tag::custom( |
| 809 | TagKind::custom("clone"), | 858 | TagKind::custom("clone"), |
| 810 | vec![format!("{}/{}/{}.git", http_url, recursive_maintainer_npub, repo_id)], | 859 | vec![format!( |
| 811 | )) | 860 | "{}/{}/{}.git", |
| 812 | .tag(Tag::custom( | 861 | http_url, recursive_maintainer_npub, repo_id |
| 813 | TagKind::custom("relays"), | 862 | )], |
| 814 | vec![relay_url], | ||
| 815 | )) | 863 | )) |
| 864 | .tag(Tag::custom(TagKind::custom("relays"), vec![relay_url])) | ||
| 816 | .tag(Tag::custom( | 865 | .tag(Tag::custom( |
| 817 | TagKind::custom("maintainers"), | 866 | TagKind::custom("maintainers"), |
| 818 | vec![ | 867 | vec![ |
| @@ -821,7 +870,12 @@ impl<'a> TestContext<'a> { | |||
| 821 | ], | 870 | ], |
| 822 | )) | 871 | )) |
| 823 | .build(self.client.recursive_maintainer_keys()) | 872 | .build(self.client.recursive_maintainer_keys()) |
| 824 | .map_err(|e| anyhow::anyhow!("Failed to build recursive maintainer repo announcement: {}", e)) | 873 | .map_err(|e| { |
| 874 | anyhow::anyhow!( | ||
| 875 | "Failed to build recursive maintainer repo announcement: {}", | ||
| 876 | e | ||
| 877 | ) | ||
| 878 | }) | ||
| 825 | } | 879 | } |
| 826 | 880 | ||
| 827 | /// Build recursive maintainer state event for the given repo_id | 881 | /// Build recursive maintainer state event for the given repo_id |
| @@ -898,7 +952,7 @@ pub async fn send_and_verify_accepted( | |||
| 898 | ) -> Result<(), String> { | 952 | ) -> Result<(), String> { |
| 899 | use nostr_sdk::prelude::Filter; | 953 | use nostr_sdk::prelude::Filter; |
| 900 | use std::time::Duration; | 954 | use std::time::Duration; |
| 901 | 955 | ||
| 902 | let event_id = event.id; | 956 | let event_id = event.id; |
| 903 | 957 | ||
| 904 | client | 958 | client |
| @@ -952,12 +1006,12 @@ pub async fn send_and_verify_rejected( | |||
| 952 | ) -> Result<(), String> { | 1006 | ) -> Result<(), String> { |
| 953 | use nostr_sdk::prelude::Filter; | 1007 | use nostr_sdk::prelude::Filter; |
| 954 | use std::time::Duration; | 1008 | use std::time::Duration; |
| 955 | 1009 | ||
| 956 | let event_id = event.id; | 1010 | let event_id = event.id; |
| 957 | 1011 | ||
| 958 | // Try to send event - rejection may cause send_event to fail with an error | 1012 | // Try to send event - rejection may cause send_event to fail with an error |
| 959 | let send_result = client.send_event(event).await; | 1013 | let send_result = client.send_event(event).await; |
| 960 | 1014 | ||
| 961 | // If send succeeded, the relay might have accepted it (we'll verify below) | 1015 | // If send succeeded, the relay might have accepted it (we'll verify below) |
| 962 | // If send failed, check if it's a rejection error (expected) | 1016 | // If send failed, check if it's a rejection error (expected) |
| 963 | if let Err(e) = send_result { | 1017 | if let Err(e) = send_result { |
| @@ -966,7 +1020,7 @@ pub async fn send_and_verify_rejected( | |||
| 966 | if err_msg.contains("rejected") || err_msg.contains("blocked") { | 1020 | if err_msg.contains("rejected") || err_msg.contains("blocked") { |
| 967 | // Expected rejection - verify event is NOT in database | 1021 | // Expected rejection - verify event is NOT in database |
| 968 | tokio::time::sleep(Duration::from_millis(100)).await; | 1022 | tokio::time::sleep(Duration::from_millis(100)).await; |
| 969 | 1023 | ||
| 970 | let filter = Filter::new().id(event_id); | 1024 | let filter = Filter::new().id(event_id); |
| 971 | let events = client | 1025 | let events = client |
| 972 | .query(filter) | 1026 | .query(filter) |
| @@ -974,9 +1028,12 @@ pub async fn send_and_verify_rejected( | |||
| 974 | .map_err(|e| format!("Failed to query relay for verification: {}", e))?; | 1028 | .map_err(|e| format!("Failed to query relay for verification: {}", e))?; |
| 975 | 1029 | ||
| 976 | if !events.is_empty() { | 1030 | if !events.is_empty() { |
| 977 | return Err(format!("Event was rejected but still stored: {}", description)); | 1031 | return Err(format!( |
| 1032 | "Event was rejected but still stored: {}", | ||
| 1033 | description | ||
| 1034 | )); | ||
| 978 | } | 1035 | } |
| 979 | 1036 | ||
| 980 | return Ok(()); // Rejected as expected | 1037 | return Ok(()); // Rejected as expected |
| 981 | } else { | 1038 | } else { |
| 982 | // Unexpected error (network, etc.) | 1039 | // Unexpected error (network, etc.) |
| @@ -1029,11 +1086,7 @@ use std::process::Command; | |||
| 1029 | /// # Ok(()) | 1086 | /// # Ok(()) |
| 1030 | /// # } | 1087 | /// # } |
| 1031 | /// ``` | 1088 | /// ``` |
| 1032 | pub fn clone_repo( | 1089 | pub fn clone_repo(relay_domain: &str, npub: &str, repo_id: &str) -> Result<PathBuf, String> { |
| 1033 | relay_domain: &str, | ||
| 1034 | npub: &str, | ||
| 1035 | repo_id: &str, | ||
| 1036 | ) -> Result<PathBuf, String> { | ||
| 1037 | let temp_base = std::env::temp_dir(); | 1090 | let temp_base = std::env::temp_dir(); |
| 1038 | let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4()); | 1091 | let clone_dir_name = format!("grasp-push-test-{}", uuid::Uuid::new_v4()); |
| 1039 | let clone_path = temp_base.join(&clone_dir_name); | 1092 | let clone_path = temp_base.join(&clone_dir_name); |
| @@ -1146,7 +1199,7 @@ impl CommitVariant { | |||
| 1146 | CommitVariant::PRTestCommit => "PR test deterministic commit", | 1199 | CommitVariant::PRTestCommit => "PR test deterministic commit", |
| 1147 | } | 1200 | } |
| 1148 | } | 1201 | } |
| 1149 | 1202 | ||
| 1150 | /// Get the commit message for this variant | 1203 | /// Get the commit message for this variant |
| 1151 | pub fn commit_message(&self) -> &'static str { | 1204 | pub fn commit_message(&self) -> &'static str { |
| 1152 | match self { | 1205 | match self { |
| @@ -1172,11 +1225,14 @@ impl CommitVariant { | |||
| 1172 | /// # Returns | 1225 | /// # Returns |
| 1173 | /// * `Ok(String)` - The deterministic commit hash | 1226 | /// * `Ok(String)` - The deterministic commit hash |
| 1174 | /// * `Err(String)` - Error message if commit failed | 1227 | /// * `Err(String)` - Error message if commit failed |
| 1175 | pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: CommitVariant) -> Result<String, String> { | 1228 | pub fn create_deterministic_commit_with_variant( |
| 1229 | clone_path: &Path, | ||
| 1230 | variant: CommitVariant, | ||
| 1231 | ) -> Result<String, String> { | ||
| 1176 | let test_file = clone_path.join("test.txt"); | 1232 | let test_file = clone_path.join("test.txt"); |
| 1177 | let content = variant.file_content(); | 1233 | let content = variant.file_content(); |
| 1178 | let message = variant.commit_message(); | 1234 | let message = variant.commit_message(); |
| 1179 | 1235 | ||
| 1180 | fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?; | 1236 | fs::write(&test_file, content).map_err(|e| format!("Failed to write file: {}", e))?; |
| 1181 | 1237 | ||
| 1182 | let output = Command::new("git") | 1238 | let output = Command::new("git") |
| @@ -1191,11 +1247,7 @@ pub fn create_deterministic_commit_with_variant(clone_path: &Path, variant: Comm | |||
| 1191 | 1247 | ||
| 1192 | // Create deterministic commit with fixed dates and GPG disabled | 1248 | // Create deterministic commit with fixed dates and GPG disabled |
| 1193 | let output = Command::new("git") | 1249 | let output = Command::new("git") |
| 1194 | .args([ | 1250 | .args(["-c", "commit.gpgsign=false", "commit", "-m", message]) |
| 1195 | "-c", "commit.gpgsign=false", | ||
| 1196 | "commit", | ||
| 1197 | "-m", message, | ||
| 1198 | ]) | ||
| 1199 | .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") | 1251 | .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") |
| 1200 | .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") | 1252 | .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") |
| 1201 | .current_dir(clone_path) | 1253 | .current_dir(clone_path) |
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index 97d068c..d8652ae 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs | |||
| @@ -21,7 +21,7 @@ | |||
| 21 | /// This hash is produced by creating a commit with: | 21 | /// This hash is produced by creating a commit with: |
| 22 | /// - File: test.txt containing "PR test deterministic commit" | 22 | /// - File: test.txt containing "PR test deterministic commit" |
| 23 | /// - Message: "PR test deterministic commit" | 23 | /// - Message: "PR test deterministic commit" |
| 24 | /// - Author: "PR Test Author <pr-test@example.com>" | 24 | /// - Author: "GRASP Audit Test <test@grasp-audit.local>" |
| 25 | /// - Author date: 2024-01-01T00:00:00Z | 25 | /// - Author date: 2024-01-01T00:00:00Z |
| 26 | /// - Committer date: 2024-01-01T00:00:00Z | 26 | /// - Committer date: 2024-01-01T00:00:00Z |
| 27 | /// - GPG signing: disabled | 27 | /// - GPG signing: disabled |
| @@ -29,13 +29,13 @@ | |||
| 29 | /// | 29 | /// |
| 30 | /// Run `test_pr_test_commit_hash_discovery` to discover/verify this value. | 30 | /// Run `test_pr_test_commit_hash_discovery` to discover/verify this value. |
| 31 | #[allow(dead_code)] | 31 | #[allow(dead_code)] |
| 32 | const PR_TEST_COMMIT_HASH: &str = "8935183ff722bf04e861928c6a7e50868c6ca4a6"; | 32 | const PR_TEST_COMMIT_HASH: &str = "5d40fb1555a0c28bf4d650515a73aaa54d4d9bfb"; |
| 33 | 33 | ||
| 34 | use crate::{ | 34 | use crate::{ |
| 35 | clone_repo, create_commit, create_deterministic_commit, create_deterministic_commit_with_variant, | 35 | clone_repo, create_commit, create_deterministic_commit, |
| 36 | try_push, try_push_to_ref, AuditClient, CommitVariant, FixtureKind, TestContext, TestResult, | 36 | create_deterministic_commit_with_variant, try_push, try_push_to_ref, AuditClient, |
| 37 | DETERMINISTIC_COMMIT_HASH, MAINTAINER_DETERMINISTIC_COMMIT_HASH, | 37 | CommitVariant, FixtureKind, TestContext, TestResult, DETERMINISTIC_COMMIT_HASH, |
| 38 | RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, | 38 | MAINTAINER_DETERMINISTIC_COMMIT_HASH, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH, |
| 39 | }; | 39 | }; |
| 40 | use nostr_sdk::prelude::*; | 40 | use nostr_sdk::prelude::*; |
| 41 | use std::fs; | 41 | use std::fs; |
| @@ -63,37 +63,121 @@ use std::process::Command; | |||
| 63 | /// * `Ok(String)` - The commit hash (should match PR_TEST_COMMIT_HASH) | 63 | /// * `Ok(String)` - The commit hash (should match PR_TEST_COMMIT_HASH) |
| 64 | /// * `Err(String)` - Error message if commit creation failed | 64 | /// * `Err(String)` - Error message if commit creation failed |
| 65 | fn create_pr_test_commit(clone_path: &Path) -> Result<String, String> { | 65 | fn create_pr_test_commit(clone_path: &Path) -> Result<String, String> { |
| 66 | // Step 1: Create orphan branch (removes all history) | 66 | // Step 1: Clean up any tracked files in the working directory |
| 67 | // This ensures we start with a clean slate | ||
| 67 | let _ = Command::new("git") | 68 | let _ = Command::new("git") |
| 68 | .args(["checkout", "--orphan", "pr-test-branch"]) | 69 | .args(["clean", "-fd"]) |
| 69 | .current_dir(clone_path) | 70 | .current_dir(clone_path) |
| 70 | .output(); | 71 | .output(); |
| 71 | 72 | ||
| 72 | // Step 2: Clear staged files (orphan keeps files staged from previous branch) | 73 | // Step 2: Create orphan branch (removes all history) |
| 73 | let _ = Command::new("git") | 74 | let output = Command::new("git") |
| 75 | .args(["checkout", "--orphan", "pr-test-branch"]) | ||
| 76 | .current_dir(clone_path) | ||
| 77 | .output() | ||
| 78 | .map_err(|e| format!("Failed to execute git checkout --orphan: {}", e))?; | ||
| 79 | |||
| 80 | if !output.status.success() { | ||
| 81 | return Err(format!( | ||
| 82 | "git checkout --orphan failed: {}", | ||
| 83 | String::from_utf8_lossy(&output.stderr) | ||
| 84 | )); | ||
| 85 | } | ||
| 86 | |||
| 87 | // Step 3: Remove ALL files from the index (staging area) | ||
| 88 | let output = Command::new("git") | ||
| 74 | .args(["rm", "-rf", "--cached", "."]) | 89 | .args(["rm", "-rf", "--cached", "."]) |
| 75 | .current_dir(clone_path) | 90 | .current_dir(clone_path) |
| 76 | .output(); | 91 | .output() |
| 92 | .map_err(|e| format!("Failed to execute git rm: {}", e))?; | ||
| 93 | |||
| 94 | // Note: git rm may return error if there are no files to remove, that's OK | ||
| 95 | if !output.status.success() { | ||
| 96 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 97 | // Ignore "did not match any files" errors | ||
| 98 | if !stderr.contains("did not match any files") { | ||
| 99 | return Err(format!("git rm -rf --cached . failed: {}", stderr)); | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | // Step 4: Remove ALL files from working directory (except .git) | ||
| 104 | // This ensures only test.txt will be in the commit | ||
| 105 | for entry in fs::read_dir(clone_path).map_err(|e| format!("Failed to read dir: {}", e))? { | ||
| 106 | let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; | ||
| 107 | let path = entry.path(); | ||
| 108 | let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); | ||
| 109 | if file_name != ".git" { | ||
| 110 | if path.is_dir() { | ||
| 111 | fs::remove_dir_all(&path) | ||
| 112 | .map_err(|e| format!("Failed to remove dir {}: {}", path.display(), e))?; | ||
| 113 | } else { | ||
| 114 | fs::remove_file(&path) | ||
| 115 | .map_err(|e| format!("Failed to remove file {}: {}", path.display(), e))?; | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | // Step 5: Create deterministic commit using existing function | ||
| 121 | let commit_hash = | ||
| 122 | create_deterministic_commit_with_variant(clone_path, CommitVariant::PRTestCommit)?; | ||
| 123 | |||
| 124 | // Step 6: Verify this is actually a root commit (no parent) | ||
| 125 | let output = Command::new("git") | ||
| 126 | .args(["rev-list", "--max-parents=0", "HEAD"]) | ||
| 127 | .current_dir(clone_path) | ||
| 128 | .output() | ||
| 129 | .map_err(|e| format!("Failed to check root commit: {}", e))?; | ||
| 77 | 130 | ||
| 78 | // Step 3: Create deterministic commit using existing function | 131 | let root_commits = String::from_utf8_lossy(&output.stdout); |
| 79 | let commit_hash = create_deterministic_commit_with_variant(clone_path, CommitVariant::PRTestCommit)?; | 132 | if !root_commits.trim().contains(&commit_hash) { |
| 133 | return Err(format!( | ||
| 134 | "Commit {} is not a root commit (has parent). Root commits: {}", | ||
| 135 | commit_hash, | ||
| 136 | root_commits.trim() | ||
| 137 | )); | ||
| 138 | } | ||
| 80 | 139 | ||
| 81 | // Step 4: Replace main branch with our new orphan branch | 140 | // Step 7: Replace main branch with our new orphan branch |
| 82 | let _ = Command::new("git") | 141 | let _ = Command::new("git") |
| 83 | .args(["branch", "-D", "main"]) | 142 | .args(["branch", "-D", "main"]) |
| 84 | .current_dir(clone_path) | 143 | .current_dir(clone_path) |
| 85 | .output(); | 144 | .output(); |
| 86 | 145 | ||
| 87 | let _ = Command::new("git") | 146 | let output = Command::new("git") |
| 88 | .args(["branch", "-m", "main"]) | 147 | .args(["branch", "-m", "main"]) |
| 89 | .current_dir(clone_path) | 148 | .current_dir(clone_path) |
| 90 | .output(); | 149 | .output() |
| 150 | .map_err(|e| format!("Failed to rename branch: {}", e))?; | ||
| 151 | |||
| 152 | if !output.status.success() { | ||
| 153 | return Err(format!( | ||
| 154 | "Failed to rename branch to main: {}", | ||
| 155 | String::from_utf8_lossy(&output.stderr) | ||
| 156 | )); | ||
| 157 | } | ||
| 91 | 158 | ||
| 92 | // Verify commit hash matches expected | 159 | // Step 8: Verify commit hash matches expected |
| 93 | if commit_hash != PR_TEST_COMMIT_HASH { | 160 | if commit_hash != PR_TEST_COMMIT_HASH { |
| 161 | // Debug: Show what's in the commit | ||
| 162 | let tree_output = Command::new("git") | ||
| 163 | .args(["ls-tree", "-r", "HEAD"]) | ||
| 164 | .current_dir(clone_path) | ||
| 165 | .output(); | ||
| 166 | let tree_info = tree_output | ||
| 167 | .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) | ||
| 168 | .unwrap_or_else(|_| "Failed to get tree".to_string()); | ||
| 169 | |||
| 170 | let cat_output = Command::new("git") | ||
| 171 | .args(["cat-file", "-p", "HEAD"]) | ||
| 172 | .current_dir(clone_path) | ||
| 173 | .output(); | ||
| 174 | let commit_info = cat_output | ||
| 175 | .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) | ||
| 176 | .unwrap_or_else(|_| "Failed to get commit".to_string()); | ||
| 177 | |||
| 94 | return Err(format!( | 178 | return Err(format!( |
| 95 | "PR test commit hash mismatch: got {}, expected {}", | 179 | "PR test commit hash mismatch: got {}, expected {}\nTree contents:\n{}\nCommit info:\n{}", |
| 96 | commit_hash, PR_TEST_COMMIT_HASH | 180 | commit_hash, PR_TEST_COMMIT_HASH, tree_info, commit_info |
| 97 | )); | 181 | )); |
| 98 | } | 182 | } |
| 99 | 183 | ||
| @@ -166,6 +250,8 @@ struct PrRefTestSetup { | |||
| 166 | repo_id: String, | 250 | repo_id: String, |
| 167 | owner_npub: String, | 251 | owner_npub: String, |
| 168 | wrong_commit_hash: String, | 252 | wrong_commit_hash: String, |
| 253 | /// The unpublished PR event - store it so we can publish the SAME event later | ||
| 254 | pr_event: Event, | ||
| 169 | } | 255 | } |
| 170 | 256 | ||
| 171 | impl PrRefTestSetup { | 257 | impl PrRefTestSetup { |
| @@ -192,17 +278,18 @@ async fn setup_repo_with_wrong_commit_pushed( | |||
| 192 | ctx: &TestContext<'_>, | 278 | ctx: &TestContext<'_>, |
| 193 | relay_domain: &str, | 279 | relay_domain: &str, |
| 194 | ) -> Result<PrRefTestSetup, String> { | 280 | ) -> Result<PrRefTestSetup, String> { |
| 195 | // Get fixtures (PREvent fixture creates the event but doesn't publish until we call get_fixture) | 281 | // Get ValidRepo fixture (publishes repo announcement to relay) |
| 196 | let repo_event = ctx | 282 | let repo_event = ctx |
| 197 | .get_fixture(FixtureKind::ValidRepo) | 283 | .get_fixture(FixtureKind::ValidRepo) |
| 198 | .await | 284 | .await |
| 199 | .map_err(|e| format!("Failed to get repo announcement: {}", e))?; | 285 | .map_err(|e| format!("Failed to get repo announcement: {}", e))?; |
| 200 | 286 | ||
| 201 | // Get PR event fixture (creates event object but doesn't publish to relay yet) | 287 | // Build PR event WITHOUT publishing - we need its ID before the event exists on relay |
| 288 | // This allows testing refs/nostr/<event-id> push behavior before the event is received | ||
| 202 | let pr_event = ctx | 289 | let pr_event = ctx |
| 203 | .get_fixture(FixtureKind::PREvent) | 290 | .build_fixture_only(FixtureKind::PREvent) |
| 204 | .await | 291 | .await |
| 205 | .map_err(|e| format!("Failed to get PR event fixture: {}", e))?; | 292 | .map_err(|e| format!("Failed to build PR event fixture: {}", e))?; |
| 206 | 293 | ||
| 207 | let repo_id = repo_event | 294 | let repo_id = repo_event |
| 208 | .tags | 295 | .tags |
| @@ -219,7 +306,8 @@ async fn setup_repo_with_wrong_commit_pushed( | |||
| 219 | let clone_path = clone_repo(relay_domain, &owner_npub, &repo_id)?; | 306 | let clone_path = clone_repo(relay_domain, &owner_npub, &repo_id)?; |
| 220 | 307 | ||
| 221 | // Create a WRONG commit (not the one expected by PR event) | 308 | // Create a WRONG commit (not the one expected by PR event) |
| 222 | let wrong_commit_hash = create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner)?; | 309 | let wrong_commit_hash = |
| 310 | create_deterministic_commit_with_variant(&clone_path, CommitVariant::Owner)?; | ||
| 223 | 311 | ||
| 224 | // Verify it's actually different from expected | 312 | // Verify it's actually different from expected |
| 225 | if wrong_commit_hash == PR_TEST_COMMIT_HASH { | 313 | if wrong_commit_hash == PR_TEST_COMMIT_HASH { |
| @@ -229,7 +317,11 @@ async fn setup_repo_with_wrong_commit_pushed( | |||
| 229 | 317 | ||
| 230 | // Push to refs/nostr/<pr-event-id> (no event published yet, should succeed) | 318 | // Push to refs/nostr/<pr-event-id> (no event published yet, should succeed) |
| 231 | let push_output = Command::new("git") | 319 | let push_output = Command::new("git") |
| 232 | .args(["push", "origin", &format!("main:refs/nostr/{}", pr_event_id)]) | 320 | .args([ |
| 321 | "push", | ||
| 322 | "origin", | ||
| 323 | &format!("master:refs/nostr/{}", pr_event_id), | ||
| 324 | ]) | ||
| 233 | .current_dir(&clone_path) | 325 | .current_dir(&clone_path) |
| 234 | .output() | 326 | .output() |
| 235 | .map_err(|e| format!("Failed to execute git push: {}", e))?; | 327 | .map_err(|e| format!("Failed to execute git push: {}", e))?; |
| @@ -249,23 +341,30 @@ async fn setup_repo_with_wrong_commit_pushed( | |||
| 249 | repo_id, | 341 | repo_id, |
| 250 | owner_npub, | 342 | owner_npub, |
| 251 | wrong_commit_hash, | 343 | wrong_commit_hash, |
| 344 | pr_event, | ||
| 252 | }) | 345 | }) |
| 253 | } | 346 | } |
| 254 | 347 | ||
| 255 | /// Publishes the PR event fixture and waits for relay to process it. | 348 | /// Publishes the SAME PR event that was built during setup. |
| 256 | /// Call this after setup_repo_with_wrong_commit_pushed to test post-event behavior. | 349 | /// Call this after setup_repo_with_wrong_commit_pushed to test post-event behavior. |
| 350 | /// | ||
| 351 | /// IMPORTANT: We must publish the EXACT same event that was used during setup, | ||
| 352 | /// otherwise the event ID won't match the refs/nostr/<event-id> ref that was pushed. | ||
| 257 | #[allow(dead_code)] | 353 | #[allow(dead_code)] |
| 258 | async fn publish_pr_event_and_wait(ctx: &TestContext<'_>) -> Result<Event, String> { | 354 | async fn publish_pr_event_and_wait( |
| 259 | // Publishing the PR event - get_fixture publishes if not already published | 355 | ctx: &TestContext<'_>, |
| 260 | let pr_event = ctx | 356 | pr_event: &Event, |
| 261 | .get_fixture(FixtureKind::PREvent) | 357 | ) -> Result<(), String> { |
| 358 | // Publish the exact same PR event that was created during setup | ||
| 359 | ctx.client() | ||
| 360 | .send_event(pr_event.clone()) | ||
| 262 | .await | 361 | .await |
| 263 | .map_err(|e| format!("Failed to publish PR event: {}", e))?; | 362 | .map_err(|e| format!("Failed to publish PR event: {}", e))?; |
| 264 | 363 | ||
| 265 | // Wait for relay to process | 364 | // Wait for relay to process |
| 266 | tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; | 365 | tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; |
| 267 | 366 | ||
| 268 | Ok(pr_event) | 367 | Ok(()) |
| 269 | } | 368 | } |
| 270 | 369 | ||
| 271 | /// Creates the correct PR test commit (matching PR_TEST_COMMIT_HASH) in an existing clone. | 370 | /// Creates the correct PR test commit (matching PR_TEST_COMMIT_HASH) in an existing clone. |
| @@ -281,7 +380,12 @@ fn reset_to_correct_pr_commit(clone_path: &Path) -> Result<String, String> { | |||
| 281 | #[allow(dead_code)] | 380 | #[allow(dead_code)] |
| 282 | fn push_to_pr_ref(clone_path: &Path, pr_event_id: &str) -> Result<bool, String> { | 381 | fn push_to_pr_ref(clone_path: &Path, pr_event_id: &str) -> Result<bool, String> { |
| 283 | let push_output = Command::new("git") | 382 | let push_output = Command::new("git") |
| 284 | .args(["push", "--force", "origin", &format!("main:refs/nostr/{}", pr_event_id)]) | 383 | .args([ |
| 384 | "push", | ||
| 385 | "--force", | ||
| 386 | "origin", | ||
| 387 | &format!("HEAD:refs/nostr/{}", pr_event_id), | ||
| 388 | ]) | ||
| 285 | .current_dir(clone_path) | 389 | .current_dir(clone_path) |
| 286 | .output() | 390 | .output() |
| 287 | .map_err(|e| format!("Failed to execute git push: {}", e))?; | 391 | .map_err(|e| format!("Failed to execute git push: {}", e))?; |
| @@ -307,22 +411,48 @@ pub struct PushAuthorizationTests; | |||
| 307 | 411 | ||
| 308 | impl PushAuthorizationTests { | 412 | impl PushAuthorizationTests { |
| 309 | /// Run all push authorization tests | 413 | /// Run all push authorization tests |
| 310 | pub async fn run_all( | 414 | pub async fn run_all(client: &AuditClient, relay_domain: &str) -> crate::AuditResult { |
| 311 | client: &AuditClient, | ||
| 312 | relay_domain: &str, | ||
| 313 | ) -> crate::AuditResult { | ||
| 314 | let mut results = crate::AuditResult::new("GRASP-01 Push Authorization Tests"); | 415 | let mut results = crate::AuditResult::new("GRASP-01 Push Authorization Tests"); |
| 315 | 416 | ||
| 316 | results.add(Self::test_push_rejected_without_state_event(client, relay_domain).await); | 417 | results.add(Self::test_push_rejected_without_state_event(client, relay_domain).await); |
| 317 | results.add(Self::test_push_authorized_by_owner_state(client, relay_domain).await); | 418 | results.add(Self::test_push_authorized_by_owner_state(client, relay_domain).await); |
| 318 | results.add(Self::test_push_rejected_wrong_commit(client, relay_domain).await); | 419 | results.add(Self::test_push_rejected_wrong_commit(client, relay_domain).await); |
| 319 | results.add(Self::test_push_authorized_by_maintainer_state_only(client, relay_domain).await); | 420 | results |
| 320 | results.add(Self::test_push_authorized_by_recursive_maintainer_state(client, relay_domain).await); | 421 | .add(Self::test_push_authorized_by_maintainer_state_only(client, relay_domain).await); |
| 321 | results.add(Self::test_push_to_nostr_ref_with_invalid_event_id_rejected(client, relay_domain).await); | 422 | results.add( |
| 322 | results.add(Self::test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received(client, relay_domain).await); | 423 | Self::test_push_authorized_by_recursive_maintainer_state(client, relay_domain).await, |
| 323 | results.add(Self::test_pr_event_published_removes_nostr_ref_at_incorrect_commit(client, relay_domain).await); | 424 | ); |
| 324 | results.add(Self::test_push_to_nostr_ref_with_wrong_commit_after_event_received_rejected(client, relay_domain).await); | 425 | results.add( |
| 325 | results.add(Self::test_push_to_nostr_ref_with_correct_commit_after_event_received_accepted(client, relay_domain).await); | 426 | Self::test_push_to_nostr_ref_with_invalid_event_id_rejected(client, relay_domain).await, |
| 427 | ); | ||
| 428 | results.add( | ||
| 429 | Self::test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received( | ||
| 430 | client, | ||
| 431 | relay_domain, | ||
| 432 | ) | ||
| 433 | .await, | ||
| 434 | ); | ||
| 435 | results.add( | ||
| 436 | Self::test_pr_event_published_removes_nostr_ref_at_incorrect_commit( | ||
| 437 | client, | ||
| 438 | relay_domain, | ||
| 439 | ) | ||
| 440 | .await, | ||
| 441 | ); | ||
| 442 | results.add( | ||
| 443 | Self::test_push_to_nostr_ref_with_wrong_commit_after_event_received_rejected( | ||
| 444 | client, | ||
| 445 | relay_domain, | ||
| 446 | ) | ||
| 447 | .await, | ||
| 448 | ); | ||
| 449 | results.add( | ||
| 450 | Self::test_push_to_nostr_ref_with_correct_commit_after_event_received_accepted( | ||
| 451 | client, | ||
| 452 | relay_domain, | ||
| 453 | ) | ||
| 454 | .await, | ||
| 455 | ); | ||
| 326 | 456 | ||
| 327 | results | 457 | results |
| 328 | } | 458 | } |
| @@ -340,26 +470,37 @@ impl PushAuthorizationTests { | |||
| 340 | Ok(r) => r, | 470 | Ok(r) => r, |
| 341 | Err(e) => { | 471 | Err(e) => { |
| 342 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") | 472 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") |
| 343 | .fail(&format!("Failed to create repo: {}", e)) | 473 | .fail(format!("Failed to create repo: {}", e)) |
| 344 | } | 474 | } |
| 345 | }; | 475 | }; |
| 346 | 476 | ||
| 347 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; | 477 | tokio::time::sleep(std::time::Duration::from_millis(200)).await; |
| 348 | 478 | ||
| 349 | let repo_id = repo.tags.iter().find(|t| t.kind() == TagKind::d()) | 479 | let repo_id = repo |
| 350 | .and_then(|t| t.content()).unwrap().to_string(); | 480 | .tags |
| 481 | .iter() | ||
| 482 | .find(|t| t.kind() == TagKind::d()) | ||
| 483 | .and_then(|t| t.content()) | ||
| 484 | .unwrap() | ||
| 485 | .to_string(); | ||
| 351 | let npub = repo.pubkey.to_bech32().unwrap(); | 486 | let npub = repo.pubkey.to_bech32().unwrap(); |
| 352 | 487 | ||
| 353 | // Clone and create commit | 488 | // Clone and create commit |
| 354 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { | 489 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { |
| 355 | Ok(p) => p, | 490 | Ok(p) => p, |
| 356 | Err(e) => return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), | 491 | Err(e) => { |
| 492 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") | ||
| 493 | .fail(&e) | ||
| 494 | } | ||
| 495 | }; | ||
| 496 | let cleanup = || { | ||
| 497 | let _ = fs::remove_dir_all(&clone_path); | ||
| 357 | }; | 498 | }; |
| 358 | let cleanup = || { let _ = fs::remove_dir_all(&clone_path); }; | ||
| 359 | 499 | ||
| 360 | if let Err(e) = create_commit(&clone_path, "Unauthorized commit") { | 500 | if let Err(e) = create_commit(&clone_path, "Unauthorized commit") { |
| 361 | cleanup(); | 501 | cleanup(); |
| 362 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e); | 502 | return TestResult::new(test_name, "GRASP-01", "Push rejected without state event") |
| 503 | .fail(&e); | ||
| 363 | } | 504 | } |
| 364 | 505 | ||
| 365 | // Do NOT publish state event - push should be rejected | 506 | // Do NOT publish state event - push should be rejected |
| @@ -367,9 +508,14 @@ impl PushAuthorizationTests { | |||
| 367 | cleanup(); | 508 | cleanup(); |
| 368 | 509 | ||
| 369 | match push_result { | 510 | match push_result { |
| 370 | Ok(false) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass(), | 511 | Ok(false) => { |
| 371 | Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail("Push accepted but should be rejected"), | 512 | TestResult::new(test_name, "GRASP-01", "Push rejected without state event").pass() |
| 372 | Err(e) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e), | 513 | } |
| 514 | Ok(true) => TestResult::new(test_name, "GRASP-01", "Push rejected without state event") | ||
| 515 | .fail("Push accepted but should be rejected"), | ||
| 516 | Err(e) => { | ||
| 517 | TestResult::new(test_name, "GRASP-01", "Push rejected without state event").fail(&e) | ||
| 518 | } | ||
| 373 | } | 519 | } |
| 374 | } | 520 | } |
| 375 | 521 | ||
| @@ -400,8 +546,12 @@ impl PushAuthorizationTests { | |||
| 400 | let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { | 546 | let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { |
| 401 | Ok(e) => e, | 547 | Ok(e) => e, |
| 402 | Err(e) => { | 548 | Err(e) => { |
| 403 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 549 | return TestResult::new( |
| 404 | .fail(&format!("Failed to create RepoState fixture: {}", e)); | 550 | test_name, |
| 551 | "GRASP-01", | ||
| 552 | "Push authorized with matching state", | ||
| 553 | ) | ||
| 554 | .fail(format!("Failed to create RepoState fixture: {}", e)); | ||
| 405 | } | 555 | } |
| 406 | }; | 556 | }; |
| 407 | 557 | ||
| @@ -416,16 +566,24 @@ impl PushAuthorizationTests { | |||
| 416 | { | 566 | { |
| 417 | Some(id) => id.to_string(), | 567 | Some(id) => id.to_string(), |
| 418 | None => { | 568 | None => { |
| 419 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 569 | return TestResult::new( |
| 420 | .fail("Missing repo_id in state event"); | 570 | test_name, |
| 571 | "GRASP-01", | ||
| 572 | "Push authorized with matching state", | ||
| 573 | ) | ||
| 574 | .fail("Missing repo_id in state event"); | ||
| 421 | } | 575 | } |
| 422 | }; | 576 | }; |
| 423 | 577 | ||
| 424 | let npub = match state_event.pubkey.to_bech32() { | 578 | let npub = match state_event.pubkey.to_bech32() { |
| 425 | Ok(n) => n, | 579 | Ok(n) => n, |
| 426 | Err(e) => { | 580 | Err(e) => { |
| 427 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 581 | return TestResult::new( |
| 428 | .fail(&format!("Failed to convert pubkey to bech32: {}", e)); | 582 | test_name, |
| 583 | "GRASP-01", | ||
| 584 | "Push authorized with matching state", | ||
| 585 | ) | ||
| 586 | .fail(format!("Failed to convert pubkey to bech32: {}", e)); | ||
| 429 | } | 587 | } |
| 430 | }; | 588 | }; |
| 431 | 589 | ||
| @@ -435,8 +593,12 @@ impl PushAuthorizationTests { | |||
| 435 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { | 593 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { |
| 436 | Ok(p) => p, | 594 | Ok(p) => p, |
| 437 | Err(e) => { | 595 | Err(e) => { |
| 438 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 596 | return TestResult::new( |
| 439 | .fail(&format!("Failed to clone repo: {}", e)); | 597 | test_name, |
| 598 | "GRASP-01", | ||
| 599 | "Push authorized with matching state", | ||
| 600 | ) | ||
| 601 | .fail(format!("Failed to clone repo: {}", e)); | ||
| 440 | } | 602 | } |
| 441 | }; | 603 | }; |
| 442 | 604 | ||
| @@ -450,8 +612,12 @@ impl PushAuthorizationTests { | |||
| 450 | Ok(h) => h, | 612 | Ok(h) => h, |
| 451 | Err(e) => { | 613 | Err(e) => { |
| 452 | cleanup(); | 614 | cleanup(); |
| 453 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 615 | return TestResult::new( |
| 454 | .fail(&format!("Failed to create deterministic commit: {}", e)); | 616 | test_name, |
| 617 | "GRASP-01", | ||
| 618 | "Push authorized with matching state", | ||
| 619 | ) | ||
| 620 | .fail(format!("Failed to create deterministic commit: {}", e)); | ||
| 455 | } | 621 | } |
| 456 | }; | 622 | }; |
| 457 | 623 | ||
| @@ -459,7 +625,7 @@ impl PushAuthorizationTests { | |||
| 459 | if commit_hash != DETERMINISTIC_COMMIT_HASH { | 625 | if commit_hash != DETERMINISTIC_COMMIT_HASH { |
| 460 | cleanup(); | 626 | cleanup(); |
| 461 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 627 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") |
| 462 | .fail(&format!( | 628 | .fail(format!( |
| 463 | "Commit hash mismatch: got {}, expected {}", | 629 | "Commit hash mismatch: got {}, expected {}", |
| 464 | commit_hash, DETERMINISTIC_COMMIT_HASH | 630 | commit_hash, DETERMINISTIC_COMMIT_HASH |
| 465 | )); | 631 | )); |
| @@ -474,16 +640,24 @@ impl PushAuthorizationTests { | |||
| 474 | match branch_output { | 640 | match branch_output { |
| 475 | Err(e) => { | 641 | Err(e) => { |
| 476 | cleanup(); | 642 | cleanup(); |
| 477 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 643 | return TestResult::new( |
| 478 | .fail(&format!("Failed to create main branch: {}", e)); | 644 | test_name, |
| 645 | "GRASP-01", | ||
| 646 | "Push authorized with matching state", | ||
| 647 | ) | ||
| 648 | .fail(format!("Failed to create main branch: {}", e)); | ||
| 479 | } | 649 | } |
| 480 | Ok(output) if !output.status.success() => { | 650 | Ok(output) if !output.status.success() => { |
| 481 | cleanup(); | 651 | cleanup(); |
| 482 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 652 | return TestResult::new( |
| 483 | .fail(&format!( | 653 | test_name, |
| 484 | "Failed to create main branch: {}", | 654 | "GRASP-01", |
| 485 | String::from_utf8_lossy(&output.stderr) | 655 | "Push authorized with matching state", |
| 486 | )); | 656 | ) |
| 657 | .fail(format!( | ||
| 658 | "Failed to create main branch: {}", | ||
| 659 | String::from_utf8_lossy(&output.stderr) | ||
| 660 | )); | ||
| 487 | } | 661 | } |
| 488 | _ => {} | 662 | _ => {} |
| 489 | } | 663 | } |
| @@ -497,16 +671,24 @@ impl PushAuthorizationTests { | |||
| 497 | match checkout_output { | 671 | match checkout_output { |
| 498 | Err(e) => { | 672 | Err(e) => { |
| 499 | cleanup(); | 673 | cleanup(); |
| 500 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 674 | return TestResult::new( |
| 501 | .fail(&format!("Failed to checkout main branch: {}", e)); | 675 | test_name, |
| 676 | "GRASP-01", | ||
| 677 | "Push authorized with matching state", | ||
| 678 | ) | ||
| 679 | .fail(format!("Failed to checkout main branch: {}", e)); | ||
| 502 | } | 680 | } |
| 503 | Ok(output) if !output.status.success() => { | 681 | Ok(output) if !output.status.success() => { |
| 504 | cleanup(); | 682 | cleanup(); |
| 505 | return TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 683 | return TestResult::new( |
| 506 | .fail(&format!( | 684 | test_name, |
| 507 | "Failed to checkout main branch: {}", | 685 | "GRASP-01", |
| 508 | String::from_utf8_lossy(&output.stderr) | 686 | "Push authorized with matching state", |
| 509 | )); | 687 | ) |
| 688 | .fail(format!( | ||
| 689 | "Failed to checkout main branch: {}", | ||
| 690 | String::from_utf8_lossy(&output.stderr) | ||
| 691 | )); | ||
| 510 | } | 692 | } |
| 511 | _ => {} | 693 | _ => {} |
| 512 | } | 694 | } |
| @@ -524,17 +706,15 @@ impl PushAuthorizationTests { | |||
| 524 | } | 706 | } |
| 525 | Ok(false) => { | 707 | Ok(false) => { |
| 526 | TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").fail( | 708 | TestResult::new(test_name, "GRASP-01", "Push authorized with matching state").fail( |
| 527 | &format!( | 709 | format!( |
| 528 | "Push was rejected but should have been accepted. \ | 710 | "Push was rejected but should have been accepted. \ |
| 529 | The state event points to commit {} which matches the pushed commit.", | 711 | The state event points to commit {} which matches the pushed commit.", |
| 530 | DETERMINISTIC_COMMIT_HASH | 712 | DETERMINISTIC_COMMIT_HASH |
| 531 | ), | 713 | ), |
| 532 | ) | 714 | ) |
| 533 | } | 715 | } |
| 534 | Err(e) => { | 716 | Err(e) => TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") |
| 535 | TestResult::new(test_name, "GRASP-01", "Push authorized with matching state") | 717 | .fail(format!("Push error: {}", e)), |
| 536 | .fail(&format!("Push error: {}", e)) | ||
| 537 | } | ||
| 538 | } | 718 | } |
| 539 | } | 719 | } |
| 540 | 720 | ||
| @@ -571,8 +751,12 @@ impl PushAuthorizationTests { | |||
| 571 | let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { | 751 | let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { |
| 572 | Ok(e) => e, | 752 | Ok(e) => e, |
| 573 | Err(e) => { | 753 | Err(e) => { |
| 574 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | 754 | return TestResult::new( |
| 575 | .fail(&format!("Failed to create RepoState fixture: {}", e)); | 755 | test_name, |
| 756 | "GRASP-01", | ||
| 757 | "Push rejected when commit not in state event", | ||
| 758 | ) | ||
| 759 | .fail(format!("Failed to create RepoState fixture: {}", e)); | ||
| 576 | } | 760 | } |
| 577 | }; | 761 | }; |
| 578 | 762 | ||
| @@ -587,16 +771,24 @@ impl PushAuthorizationTests { | |||
| 587 | { | 771 | { |
| 588 | Some(id) => id.to_string(), | 772 | Some(id) => id.to_string(), |
| 589 | None => { | 773 | None => { |
| 590 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | 774 | return TestResult::new( |
| 591 | .fail("Missing repo_id in state event"); | 775 | test_name, |
| 776 | "GRASP-01", | ||
| 777 | "Push rejected when commit not in state event", | ||
| 778 | ) | ||
| 779 | .fail("Missing repo_id in state event"); | ||
| 592 | } | 780 | } |
| 593 | }; | 781 | }; |
| 594 | 782 | ||
| 595 | let npub = match state_event.pubkey.to_bech32() { | 783 | let npub = match state_event.pubkey.to_bech32() { |
| 596 | Ok(n) => n, | 784 | Ok(n) => n, |
| 597 | Err(e) => { | 785 | Err(e) => { |
| 598 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | 786 | return TestResult::new( |
| 599 | .fail(&format!("Failed to convert pubkey to bech32: {}", e)); | 787 | test_name, |
| 788 | "GRASP-01", | ||
| 789 | "Push rejected when commit not in state event", | ||
| 790 | ) | ||
| 791 | .fail(format!("Failed to convert pubkey to bech32: {}", e)); | ||
| 600 | } | 792 | } |
| 601 | }; | 793 | }; |
| 602 | 794 | ||
| @@ -607,8 +799,12 @@ impl PushAuthorizationTests { | |||
| 607 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { | 799 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { |
| 608 | Ok(p) => p, | 800 | Ok(p) => p, |
| 609 | Err(e) => { | 801 | Err(e) => { |
| 610 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | 802 | return TestResult::new( |
| 611 | .fail(&format!("Failed to clone repo: {}", e)); | 803 | test_name, |
| 804 | "GRASP-01", | ||
| 805 | "Push rejected when commit not in state event", | ||
| 806 | ) | ||
| 807 | .fail(format!("Failed to clone repo: {}", e)); | ||
| 612 | } | 808 | } |
| 613 | }; | 809 | }; |
| 614 | 810 | ||
| @@ -626,16 +822,24 @@ impl PushAuthorizationTests { | |||
| 626 | match branch_output { | 822 | match branch_output { |
| 627 | Err(e) => { | 823 | Err(e) => { |
| 628 | cleanup(); | 824 | cleanup(); |
| 629 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | 825 | return TestResult::new( |
| 630 | .fail(&format!("Failed to create/checkout main branch: {}", e)); | 826 | test_name, |
| 827 | "GRASP-01", | ||
| 828 | "Push rejected when commit not in state event", | ||
| 829 | ) | ||
| 830 | .fail(format!("Failed to create/checkout main branch: {}", e)); | ||
| 631 | } | 831 | } |
| 632 | Ok(output) if !output.status.success() => { | 832 | Ok(output) if !output.status.success() => { |
| 633 | cleanup(); | 833 | cleanup(); |
| 634 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | 834 | return TestResult::new( |
| 635 | .fail(&format!( | 835 | test_name, |
| 636 | "Failed to create/checkout main branch: {}", | 836 | "GRASP-01", |
| 637 | String::from_utf8_lossy(&output.stderr) | 837 | "Push rejected when commit not in state event", |
| 638 | )); | 838 | ) |
| 839 | .fail(format!( | ||
| 840 | "Failed to create/checkout main branch: {}", | ||
| 841 | String::from_utf8_lossy(&output.stderr) | ||
| 842 | )); | ||
| 639 | } | 843 | } |
| 640 | _ => {} | 844 | _ => {} |
| 641 | } | 845 | } |
| @@ -644,8 +848,12 @@ impl PushAuthorizationTests { | |||
| 644 | // Any commit hash different from what's authorized in the state event will work | 848 | // Any commit hash different from what's authorized in the state event will work |
| 645 | if let Err(e) = create_commit(&clone_path, "Unauthorized commit - should be rejected") { | 849 | if let Err(e) = create_commit(&clone_path, "Unauthorized commit - should be rejected") { |
| 646 | cleanup(); | 850 | cleanup(); |
| 647 | return TestResult::new(test_name, "GRASP-01", "Push rejected when commit not in state event") | 851 | return TestResult::new( |
| 648 | .fail(&format!("Failed to create wrong commit: {}", e)); | 852 | test_name, |
| 853 | "GRASP-01", | ||
| 854 | "Push rejected when commit not in state event", | ||
| 855 | ) | ||
| 856 | .fail(format!("Failed to create wrong commit: {}", e)); | ||
| 649 | } | 857 | } |
| 650 | 858 | ||
| 651 | // ============================================================ | 859 | // ============================================================ |
| @@ -703,7 +911,7 @@ impl PushAuthorizationTests { | |||
| 703 | "GRASP-01", | 911 | "GRASP-01", |
| 704 | "Push authorized by maintainer state event only (no announcement)", | 912 | "Push authorized by maintainer state event only (no announcement)", |
| 705 | ) | 913 | ) |
| 706 | .fail(&format!("Failed to create RepoState fixture: {}", e)); | 914 | .fail(format!("Failed to create RepoState fixture: {}", e)); |
| 707 | } | 915 | } |
| 708 | }; | 916 | }; |
| 709 | 917 | ||
| @@ -717,7 +925,7 @@ impl PushAuthorizationTests { | |||
| 717 | "GRASP-01", | 925 | "GRASP-01", |
| 718 | "Push authorized by maintainer state event only (no announcement)", | 926 | "Push authorized by maintainer state event only (no announcement)", |
| 719 | ) | 927 | ) |
| 720 | .fail(&format!("Failed to create MaintainerState fixture: {}", e)); | 928 | .fail(format!("Failed to create MaintainerState fixture: {}", e)); |
| 721 | } | 929 | } |
| 722 | }; | 930 | }; |
| 723 | 931 | ||
| @@ -749,7 +957,7 @@ impl PushAuthorizationTests { | |||
| 749 | "GRASP-01", | 957 | "GRASP-01", |
| 750 | "Push authorized by maintainer state event only (no announcement)", | 958 | "Push authorized by maintainer state event only (no announcement)", |
| 751 | ) | 959 | ) |
| 752 | .fail(&format!("Failed to convert pubkey to bech32: {}", e)); | 960 | .fail(format!("Failed to convert pubkey to bech32: {}", e)); |
| 753 | } | 961 | } |
| 754 | }; | 962 | }; |
| 755 | 963 | ||
| @@ -785,19 +993,21 @@ impl PushAuthorizationTests { | |||
| 785 | .output(); | 993 | .output(); |
| 786 | 994 | ||
| 787 | // Step 3: Create deterministic commit using existing function | 995 | // Step 3: Create deterministic commit using existing function |
| 788 | let commit_hash = | 996 | let commit_hash = match create_deterministic_commit_with_variant( |
| 789 | match create_deterministic_commit_with_variant(&clone_path, CommitVariant::Maintainer) { | 997 | &clone_path, |
| 790 | Ok(h) => h, | 998 | CommitVariant::Maintainer, |
| 791 | Err(e) => { | 999 | ) { |
| 792 | cleanup(); | 1000 | Ok(h) => h, |
| 793 | return TestResult::new( | 1001 | Err(e) => { |
| 794 | test_name, | 1002 | cleanup(); |
| 795 | "GRASP-01", | 1003 | return TestResult::new( |
| 796 | "Push authorized by maintainer state event only (no announcement)", | 1004 | test_name, |
| 797 | ) | 1005 | "GRASP-01", |
| 798 | .fail(&format!("Failed to create maintainer commit: {}", e)); | 1006 | "Push authorized by maintainer state event only (no announcement)", |
| 799 | } | 1007 | ) |
| 800 | }; | 1008 | .fail(format!("Failed to create maintainer commit: {}", e)); |
| 1009 | } | ||
| 1010 | }; | ||
| 801 | 1011 | ||
| 802 | // Step 4: Replace main branch with our new orphan branch | 1012 | // Step 4: Replace main branch with our new orphan branch |
| 803 | let _ = Command::new("git") | 1013 | let _ = Command::new("git") |
| @@ -818,7 +1028,7 @@ impl PushAuthorizationTests { | |||
| 818 | "GRASP-01", | 1028 | "GRASP-01", |
| 819 | "Push authorized by maintainer state event only (no announcement)", | 1029 | "Push authorized by maintainer state event only (no announcement)", |
| 820 | ) | 1030 | ) |
| 821 | .fail(&format!( | 1031 | .fail(format!( |
| 822 | "Maintainer commit hash mismatch: got {}, expected {}", | 1032 | "Maintainer commit hash mismatch: got {}, expected {}", |
| 823 | commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH | 1033 | commit_hash, MAINTAINER_DETERMINISTIC_COMMIT_HASH |
| 824 | )); | 1034 | )); |
| @@ -843,7 +1053,7 @@ impl PushAuthorizationTests { | |||
| 843 | "GRASP-01", | 1053 | "GRASP-01", |
| 844 | "Push authorized by maintainer state event only (no announcement)", | 1054 | "Push authorized by maintainer state event only (no announcement)", |
| 845 | ) | 1055 | ) |
| 846 | .fail(&format!( | 1056 | .fail(format!( |
| 847 | "Push was rejected but should have been accepted. \ | 1057 | "Push was rejected but should have been accepted. \ |
| 848 | The maintainer published a state event with commit {}, \ | 1058 | The maintainer published a state event with commit {}, \ |
| 849 | and even without a separate announcement, the relay should \ | 1059 | and even without a separate announcement, the relay should \ |
| @@ -856,7 +1066,7 @@ impl PushAuthorizationTests { | |||
| 856 | "GRASP-01", | 1066 | "GRASP-01", |
| 857 | "Push authorized by maintainer state event only (no announcement)", | 1067 | "Push authorized by maintainer state event only (no announcement)", |
| 858 | ) | 1068 | ) |
| 859 | .fail(&format!("Push error: {}", e)), | 1069 | .fail(format!("Push error: {}", e)), |
| 860 | } | 1070 | } |
| 861 | } | 1071 | } |
| 862 | 1072 | ||
| @@ -899,7 +1109,7 @@ impl PushAuthorizationTests { | |||
| 899 | "GRASP-01", | 1109 | "GRASP-01", |
| 900 | "Push authorized by recursive maintainer state event", | 1110 | "Push authorized by recursive maintainer state event", |
| 901 | ) | 1111 | ) |
| 902 | .fail(&format!("Failed to create RepoState fixture: {}", e)); | 1112 | .fail(format!("Failed to create RepoState fixture: {}", e)); |
| 903 | } | 1113 | } |
| 904 | }; | 1114 | }; |
| 905 | 1115 | ||
| @@ -912,7 +1122,10 @@ impl PushAuthorizationTests { | |||
| 912 | "GRASP-01", | 1122 | "GRASP-01", |
| 913 | "Push authorized by recursive maintainer state event", | 1123 | "Push authorized by recursive maintainer state event", |
| 914 | ) | 1124 | ) |
| 915 | .fail(&format!("Failed to create MaintainerAnnouncement fixture: {}", e)); | 1125 | .fail(format!( |
| 1126 | "Failed to create MaintainerAnnouncement fixture: {}", | ||
| 1127 | e | ||
| 1128 | )); | ||
| 916 | } | 1129 | } |
| 917 | }; | 1130 | }; |
| 918 | 1131 | ||
| @@ -925,12 +1138,15 @@ impl PushAuthorizationTests { | |||
| 925 | "GRASP-01", | 1138 | "GRASP-01", |
| 926 | "Push authorized by recursive maintainer state event", | 1139 | "Push authorized by recursive maintainer state event", |
| 927 | ) | 1140 | ) |
| 928 | .fail(&format!("Failed to create MaintainerState fixture: {}", e)); | 1141 | .fail(format!("Failed to create MaintainerState fixture: {}", e)); |
| 929 | } | 1142 | } |
| 930 | }; | 1143 | }; |
| 931 | 1144 | ||
| 932 | // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain) | 1145 | // Get RecursiveMaintainerRepoAndState fixture (completes 3-level delegation chain) |
| 933 | match ctx.get_fixture(FixtureKind::RecursiveMaintainerRepoAndState).await { | 1146 | match ctx |
| 1147 | .get_fixture(FixtureKind::RecursiveMaintainerRepoAndState) | ||
| 1148 | .await | ||
| 1149 | { | ||
| 934 | Ok(_) => {} | 1150 | Ok(_) => {} |
| 935 | Err(e) => { | 1151 | Err(e) => { |
| 936 | return TestResult::new( | 1152 | return TestResult::new( |
| @@ -938,7 +1154,10 @@ impl PushAuthorizationTests { | |||
| 938 | "GRASP-01", | 1154 | "GRASP-01", |
| 939 | "Push authorized by recursive maintainer state event", | 1155 | "Push authorized by recursive maintainer state event", |
| 940 | ) | 1156 | ) |
| 941 | .fail(&format!("Failed to create RecursiveMaintainerRepoAndState fixture: {}", e)); | 1157 | .fail(format!( |
| 1158 | "Failed to create RecursiveMaintainerRepoAndState fixture: {}", | ||
| 1159 | e | ||
| 1160 | )); | ||
| 942 | } | 1161 | } |
| 943 | }; | 1162 | }; |
| 944 | 1163 | ||
| @@ -970,7 +1189,7 @@ impl PushAuthorizationTests { | |||
| 970 | "GRASP-01", | 1189 | "GRASP-01", |
| 971 | "Push authorized by recursive maintainer state event", | 1190 | "Push authorized by recursive maintainer state event", |
| 972 | ) | 1191 | ) |
| 973 | .fail(&format!("Failed to convert pubkey to bech32: {}", e)); | 1192 | .fail(format!("Failed to convert pubkey to bech32: {}", e)); |
| 974 | } | 1193 | } |
| 975 | }; | 1194 | }; |
| 976 | 1195 | ||
| @@ -1006,19 +1225,24 @@ impl PushAuthorizationTests { | |||
| 1006 | .output(); | 1225 | .output(); |
| 1007 | 1226 | ||
| 1008 | // Step 3: Create recursive maintainer deterministic commit | 1227 | // Step 3: Create recursive maintainer deterministic commit |
| 1009 | let commit_hash = | 1228 | let commit_hash = match create_deterministic_commit_with_variant( |
| 1010 | match create_deterministic_commit_with_variant(&clone_path, CommitVariant::RecursiveMaintainer) { | 1229 | &clone_path, |
| 1011 | Ok(h) => h, | 1230 | CommitVariant::RecursiveMaintainer, |
| 1012 | Err(e) => { | 1231 | ) { |
| 1013 | cleanup(); | 1232 | Ok(h) => h, |
| 1014 | return TestResult::new( | 1233 | Err(e) => { |
| 1015 | test_name, | 1234 | cleanup(); |
| 1016 | "GRASP-01", | 1235 | return TestResult::new( |
| 1017 | "Push authorized by recursive maintainer state event", | 1236 | test_name, |
| 1018 | ) | 1237 | "GRASP-01", |
| 1019 | .fail(&format!("Failed to create recursive maintainer commit: {}", e)); | 1238 | "Push authorized by recursive maintainer state event", |
| 1020 | } | 1239 | ) |
| 1021 | }; | 1240 | .fail(format!( |
| 1241 | "Failed to create recursive maintainer commit: {}", | ||
| 1242 | e | ||
| 1243 | )); | ||
| 1244 | } | ||
| 1245 | }; | ||
| 1022 | 1246 | ||
| 1023 | // Step 4: Replace main branch with our new orphan branch | 1247 | // Step 4: Replace main branch with our new orphan branch |
| 1024 | let _ = Command::new("git") | 1248 | let _ = Command::new("git") |
| @@ -1039,7 +1263,7 @@ impl PushAuthorizationTests { | |||
| 1039 | "GRASP-01", | 1263 | "GRASP-01", |
| 1040 | "Push authorized by recursive maintainer state event", | 1264 | "Push authorized by recursive maintainer state event", |
| 1041 | ) | 1265 | ) |
| 1042 | .fail(&format!( | 1266 | .fail(format!( |
| 1043 | "Recursive maintainer commit hash mismatch: got {}, expected {}", | 1267 | "Recursive maintainer commit hash mismatch: got {}, expected {}", |
| 1044 | commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH | 1268 | commit_hash, RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH |
| 1045 | )); | 1269 | )); |
| @@ -1064,7 +1288,7 @@ impl PushAuthorizationTests { | |||
| 1064 | "GRASP-01", | 1288 | "GRASP-01", |
| 1065 | "Push authorized by recursive maintainer state event", | 1289 | "Push authorized by recursive maintainer state event", |
| 1066 | ) | 1290 | ) |
| 1067 | .fail(&format!( | 1291 | .fail(format!( |
| 1068 | "Push was rejected but should have been accepted. \ | 1292 | "Push was rejected but should have been accepted. \ |
| 1069 | The recursive maintainer published a state event with commit {}, \ | 1293 | The recursive maintainer published a state event with commit {}, \ |
| 1070 | and the relay should authorize pushes matching this state event \ | 1294 | and the relay should authorize pushes matching this state event \ |
| @@ -1076,7 +1300,7 @@ impl PushAuthorizationTests { | |||
| 1076 | "GRASP-01", | 1300 | "GRASP-01", |
| 1077 | "Push authorized by recursive maintainer state event", | 1301 | "Push authorized by recursive maintainer state event", |
| 1078 | ) | 1302 | ) |
| 1079 | .fail(&format!("Push error: {}", e)), | 1303 | .fail(format!("Push error: {}", e)), |
| 1080 | } | 1304 | } |
| 1081 | } | 1305 | } |
| 1082 | 1306 | ||
| @@ -1109,8 +1333,12 @@ impl PushAuthorizationTests { | |||
| 1109 | let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { | 1333 | let state_event = match ctx.get_fixture(FixtureKind::RepoState).await { |
| 1110 | Ok(e) => e, | 1334 | Ok(e) => e, |
| 1111 | Err(e) => { | 1335 | Err(e) => { |
| 1112 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1336 | return TestResult::new( |
| 1113 | .fail(&format!("Failed to create RepoState fixture: {}", e)); | 1337 | test_name, |
| 1338 | "GRASP-01", | ||
| 1339 | "Non-maintainer state events ignored", | ||
| 1340 | ) | ||
| 1341 | .fail(format!("Failed to create RepoState fixture: {}", e)); | ||
| 1114 | } | 1342 | } |
| 1115 | }; | 1343 | }; |
| 1116 | 1344 | ||
| @@ -1125,16 +1353,24 @@ impl PushAuthorizationTests { | |||
| 1125 | { | 1353 | { |
| 1126 | Some(id) => id.to_string(), | 1354 | Some(id) => id.to_string(), |
| 1127 | None => { | 1355 | None => { |
| 1128 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1356 | return TestResult::new( |
| 1129 | .fail("Missing repo_id in state event"); | 1357 | test_name, |
| 1358 | "GRASP-01", | ||
| 1359 | "Non-maintainer state events ignored", | ||
| 1360 | ) | ||
| 1361 | .fail("Missing repo_id in state event"); | ||
| 1130 | } | 1362 | } |
| 1131 | }; | 1363 | }; |
| 1132 | 1364 | ||
| 1133 | let npub = match state_event.pubkey.to_bech32() { | 1365 | let npub = match state_event.pubkey.to_bech32() { |
| 1134 | Ok(n) => n, | 1366 | Ok(n) => n, |
| 1135 | Err(e) => { | 1367 | Err(e) => { |
| 1136 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1368 | return TestResult::new( |
| 1137 | .fail(&format!("Failed to convert pubkey to bech32: {}", e)); | 1369 | test_name, |
| 1370 | "GRASP-01", | ||
| 1371 | "Non-maintainer state events ignored", | ||
| 1372 | ) | ||
| 1373 | .fail(format!("Failed to convert pubkey to bech32: {}", e)); | ||
| 1138 | } | 1374 | } |
| 1139 | }; | 1375 | }; |
| 1140 | 1376 | ||
| @@ -1145,8 +1381,12 @@ impl PushAuthorizationTests { | |||
| 1145 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { | 1381 | let clone_path = match clone_repo(relay_domain, &npub, &repo_id) { |
| 1146 | Ok(p) => p, | 1382 | Ok(p) => p, |
| 1147 | Err(e) => { | 1383 | Err(e) => { |
| 1148 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1384 | return TestResult::new( |
| 1149 | .fail(&format!("Failed to clone repo: {}", e)); | 1385 | test_name, |
| 1386 | "GRASP-01", | ||
| 1387 | "Non-maintainer state events ignored", | ||
| 1388 | ) | ||
| 1389 | .fail(format!("Failed to clone repo: {}", e)); | ||
| 1150 | } | 1390 | } |
| 1151 | }; | 1391 | }; |
| 1152 | 1392 | ||
| @@ -1160,8 +1400,12 @@ impl PushAuthorizationTests { | |||
| 1160 | Ok(h) => h, | 1400 | Ok(h) => h, |
| 1161 | Err(e) => { | 1401 | Err(e) => { |
| 1162 | cleanup(); | 1402 | cleanup(); |
| 1163 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1403 | return TestResult::new( |
| 1164 | .fail(&format!("Failed to create deterministic commit: {}", e)); | 1404 | test_name, |
| 1405 | "GRASP-01", | ||
| 1406 | "Non-maintainer state events ignored", | ||
| 1407 | ) | ||
| 1408 | .fail(format!("Failed to create deterministic commit: {}", e)); | ||
| 1165 | } | 1409 | } |
| 1166 | }; | 1410 | }; |
| 1167 | 1411 | ||
| @@ -1169,7 +1413,7 @@ impl PushAuthorizationTests { | |||
| 1169 | if commit_hash != DETERMINISTIC_COMMIT_HASH { | 1413 | if commit_hash != DETERMINISTIC_COMMIT_HASH { |
| 1170 | cleanup(); | 1414 | cleanup(); |
| 1171 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1415 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") |
| 1172 | .fail(&format!( | 1416 | .fail(format!( |
| 1173 | "Commit hash mismatch: got {}, expected {}", | 1417 | "Commit hash mismatch: got {}, expected {}", |
| 1174 | commit_hash, DETERMINISTIC_COMMIT_HASH | 1418 | commit_hash, DETERMINISTIC_COMMIT_HASH |
| 1175 | )); | 1419 | )); |
| @@ -1184,16 +1428,24 @@ impl PushAuthorizationTests { | |||
| 1184 | match branch_output { | 1428 | match branch_output { |
| 1185 | Err(e) => { | 1429 | Err(e) => { |
| 1186 | cleanup(); | 1430 | cleanup(); |
| 1187 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1431 | return TestResult::new( |
| 1188 | .fail(&format!("Failed to create main branch: {}", e)); | 1432 | test_name, |
| 1433 | "GRASP-01", | ||
| 1434 | "Non-maintainer state events ignored", | ||
| 1435 | ) | ||
| 1436 | .fail(format!("Failed to create main branch: {}", e)); | ||
| 1189 | } | 1437 | } |
| 1190 | Ok(output) if !output.status.success() => { | 1438 | Ok(output) if !output.status.success() => { |
| 1191 | cleanup(); | 1439 | cleanup(); |
| 1192 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1440 | return TestResult::new( |
| 1193 | .fail(&format!( | 1441 | test_name, |
| 1194 | "Failed to create main branch: {}", | 1442 | "GRASP-01", |
| 1195 | String::from_utf8_lossy(&output.stderr) | 1443 | "Non-maintainer state events ignored", |
| 1196 | )); | 1444 | ) |
| 1445 | .fail(format!( | ||
| 1446 | "Failed to create main branch: {}", | ||
| 1447 | String::from_utf8_lossy(&output.stderr) | ||
| 1448 | )); | ||
| 1197 | } | 1449 | } |
| 1198 | _ => {} | 1450 | _ => {} |
| 1199 | } | 1451 | } |
| @@ -1207,16 +1459,24 @@ impl PushAuthorizationTests { | |||
| 1207 | match checkout_output { | 1459 | match checkout_output { |
| 1208 | Err(e) => { | 1460 | Err(e) => { |
| 1209 | cleanup(); | 1461 | cleanup(); |
| 1210 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1462 | return TestResult::new( |
| 1211 | .fail(&format!("Failed to checkout main branch: {}", e)); | 1463 | test_name, |
| 1464 | "GRASP-01", | ||
| 1465 | "Non-maintainer state events ignored", | ||
| 1466 | ) | ||
| 1467 | .fail(format!("Failed to checkout main branch: {}", e)); | ||
| 1212 | } | 1468 | } |
| 1213 | Ok(output) if !output.status.success() => { | 1469 | Ok(output) if !output.status.success() => { |
| 1214 | cleanup(); | 1470 | cleanup(); |
| 1215 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1471 | return TestResult::new( |
| 1216 | .fail(&format!( | 1472 | test_name, |
| 1217 | "Failed to checkout main branch: {}", | 1473 | "GRASP-01", |
| 1218 | String::from_utf8_lossy(&output.stderr) | 1474 | "Non-maintainer state events ignored", |
| 1219 | )); | 1475 | ) |
| 1476 | .fail(format!( | ||
| 1477 | "Failed to checkout main branch: {}", | ||
| 1478 | String::from_utf8_lossy(&output.stderr) | ||
| 1479 | )); | ||
| 1220 | } | 1480 | } |
| 1221 | _ => {} | 1481 | _ => {} |
| 1222 | } | 1482 | } |
| @@ -1231,16 +1491,24 @@ impl PushAuthorizationTests { | |||
| 1231 | match push_output { | 1491 | match push_output { |
| 1232 | Err(e) => { | 1492 | Err(e) => { |
| 1233 | cleanup(); | 1493 | cleanup(); |
| 1234 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1494 | return TestResult::new( |
| 1235 | .fail(&format!("Failed to push initial commit: {}", e)); | 1495 | test_name, |
| 1496 | "GRASP-01", | ||
| 1497 | "Non-maintainer state events ignored", | ||
| 1498 | ) | ||
| 1499 | .fail(format!("Failed to push initial commit: {}", e)); | ||
| 1236 | } | 1500 | } |
| 1237 | Ok(output) if !output.status.success() => { | 1501 | Ok(output) if !output.status.success() => { |
| 1238 | cleanup(); | 1502 | cleanup(); |
| 1239 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1503 | return TestResult::new( |
| 1240 | .fail(&format!( | 1504 | test_name, |
| 1241 | "Failed to push initial commit: {}", | 1505 | "GRASP-01", |
| 1242 | String::from_utf8_lossy(&output.stderr) | 1506 | "Non-maintainer state events ignored", |
| 1243 | )); | 1507 | ) |
| 1508 | .fail(format!( | ||
| 1509 | "Failed to push initial commit: {}", | ||
| 1510 | String::from_utf8_lossy(&output.stderr) | ||
| 1511 | )); | ||
| 1244 | } | 1512 | } |
| 1245 | _ => {} | 1513 | _ => {} |
| 1246 | } | 1514 | } |
| @@ -1253,14 +1521,18 @@ impl PushAuthorizationTests { | |||
| 1253 | Ok(h) => h, | 1521 | Ok(h) => h, |
| 1254 | Err(e) => { | 1522 | Err(e) => { |
| 1255 | cleanup(); | 1523 | cleanup(); |
| 1256 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1524 | return TestResult::new( |
| 1257 | .fail(&format!("Failed to create commit: {}", e)); | 1525 | test_name, |
| 1526 | "GRASP-01", | ||
| 1527 | "Non-maintainer state events ignored", | ||
| 1528 | ) | ||
| 1529 | .fail(format!("Failed to create commit: {}", e)); | ||
| 1258 | } | 1530 | } |
| 1259 | }; | 1531 | }; |
| 1260 | 1532 | ||
| 1261 | // Create a rogue keypair (NOT the maintainer) | 1533 | // Create a rogue keypair (NOT the maintainer) |
| 1262 | let rogue_keys = Keys::generate(); | 1534 | let rogue_keys = Keys::generate(); |
| 1263 | 1535 | ||
| 1264 | // Create a rogue state event announcing the new commit | 1536 | // Create a rogue state event announcing the new commit |
| 1265 | // This event has the correct repo_id but is signed by a non-maintainer | 1537 | // This event has the correct repo_id but is signed by a non-maintainer |
| 1266 | let rogue_state = match client | 1538 | let rogue_state = match client |
| @@ -1275,8 +1547,12 @@ impl PushAuthorizationTests { | |||
| 1275 | Ok(e) => e, | 1547 | Ok(e) => e, |
| 1276 | Err(e) => { | 1548 | Err(e) => { |
| 1277 | cleanup(); | 1549 | cleanup(); |
| 1278 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1550 | return TestResult::new( |
| 1279 | .fail(&format!("Failed to build rogue state event: {}", e)); | 1551 | test_name, |
| 1552 | "GRASP-01", | ||
| 1553 | "Non-maintainer state events ignored", | ||
| 1554 | ) | ||
| 1555 | .fail(format!("Failed to build rogue state event: {}", e)); | ||
| 1280 | } | 1556 | } |
| 1281 | }; | 1557 | }; |
| 1282 | 1558 | ||
| @@ -1284,7 +1560,7 @@ impl PushAuthorizationTests { | |||
| 1284 | if let Err(e) = client.client().send_event(&rogue_state).await { | 1560 | if let Err(e) = client.client().send_event(&rogue_state).await { |
| 1285 | cleanup(); | 1561 | cleanup(); |
| 1286 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1562 | return TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") |
| 1287 | .fail(&format!("Failed to send rogue state event: {}", e)); | 1563 | .fail(format!("Failed to send rogue state event: {}", e)); |
| 1288 | } | 1564 | } |
| 1289 | 1565 | ||
| 1290 | // Wait for event to propagate | 1566 | // Wait for event to propagate |
| @@ -1300,7 +1576,7 @@ impl PushAuthorizationTests { | |||
| 1300 | match push_result { | 1576 | match push_result { |
| 1301 | Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(), | 1577 | Ok(false) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored").pass(), |
| 1302 | Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") | 1578 | Ok(true) => TestResult::new(test_name, "GRASP-01", "Non-maintainer state events ignored") |
| 1303 | .fail(&format!( | 1579 | .fail(format!( |
| 1304 | "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \ | 1580 | "Push accepted but should be rejected. A non-maintainer (pubkey: {}) published \ |
| 1305 | a state event announcing commit {}, but the push was accepted. The relay should \ | 1581 | a state event announcing commit {}, but the push was accepted. The relay should \ |
| 1306 | only accept state events from maintainers (pubkey: {}).", | 1582 | only accept state events from maintainers (pubkey: {}).", |
| @@ -1342,7 +1618,7 @@ impl PushAuthorizationTests { | |||
| 1342 | "GRASP-01", | 1618 | "GRASP-01", |
| 1343 | "Push to refs/nostr/<invalid-event-id> rejected", | 1619 | "Push to refs/nostr/<invalid-event-id> rejected", |
| 1344 | ) | 1620 | ) |
| 1345 | .fail(&format!("Failed to create repo: {}", e)); | 1621 | .fail(format!("Failed to create repo: {}", e)); |
| 1346 | } | 1622 | } |
| 1347 | }; | 1623 | }; |
| 1348 | 1624 | ||
| @@ -1408,7 +1684,7 @@ impl PushAuthorizationTests { | |||
| 1408 | "GRASP-01", | 1684 | "GRASP-01", |
| 1409 | "Push to refs/nostr/<invalid-event-id> rejected", | 1685 | "Push to refs/nostr/<invalid-event-id> rejected", |
| 1410 | ) | 1686 | ) |
| 1411 | .fail(&format!( | 1687 | .fail(format!( |
| 1412 | "Push to {} was accepted but should be rejected. \ | 1688 | "Push to {} was accepted but should be rejected. \ |
| 1413 | The event-id '{}' is NOT a valid 64-character hex string (EventId format). \ | 1689 | The event-id '{}' is NOT a valid 64-character hex string (EventId format). \ |
| 1414 | The relay should reject pushes to refs/nostr/ with invalid event-id format.", | 1690 | The relay should reject pushes to refs/nostr/ with invalid event-id format.", |
| @@ -1419,7 +1695,7 @@ impl PushAuthorizationTests { | |||
| 1419 | "GRASP-01", | 1695 | "GRASP-01", |
| 1420 | "Push to refs/nostr/<invalid-event-id> rejected", | 1696 | "Push to refs/nostr/<invalid-event-id> rejected", |
| 1421 | ) | 1697 | ) |
| 1422 | .fail(&format!("Push error: {}", e)), | 1698 | .fail(format!("Push error: {}", e)), |
| 1423 | } | 1699 | } |
| 1424 | } | 1700 | } |
| 1425 | 1701 | ||
| @@ -1434,7 +1710,8 @@ impl PushAuthorizationTests { | |||
| 1434 | client: &AuditClient, | 1710 | client: &AuditClient, |
| 1435 | relay_domain: &str, | 1711 | relay_domain: &str, |
| 1436 | ) -> TestResult { | 1712 | ) -> TestResult { |
| 1437 | let test_name = "test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received"; | 1713 | let test_name = |
| 1714 | "test_pr_push_to_nostr_ref_with_wrong_commit_accepted_before_event_received"; | ||
| 1438 | let desc = "Push wrong commit to refs/nostr/<pr-event-id> before PR event (should accept)"; | 1715 | let desc = "Push wrong commit to refs/nostr/<pr-event-id> before PR event (should accept)"; |
| 1439 | let ctx = TestContext::new(client); | 1716 | let ctx = TestContext::new(client); |
| 1440 | 1717 | ||
| @@ -1459,15 +1736,13 @@ impl PushAuthorizationTests { | |||
| 1459 | /// the relay should validate any existing refs/nostr/<event-id> refs and | 1736 | /// the relay should validate any existing refs/nostr/<event-id> refs and |
| 1460 | /// delete those that don't match the commit in the PR event's `c` tag. | 1737 | /// delete those that don't match the commit in the PR event's `c` tag. |
| 1461 | /// | 1738 | /// |
| 1462 | /// Currently NOT_IMPLEMENTED - the relay doesn't have this cleanup logic yet. | ||
| 1463 | /// | ||
| 1464 | /// Depends on: `setup_repo_with_wrong_commit_pushed` (wrong commit already pushed) | 1739 | /// Depends on: `setup_repo_with_wrong_commit_pushed` (wrong commit already pushed) |
| 1465 | pub async fn test_pr_event_published_removes_nostr_ref_at_incorrect_commit( | 1740 | pub async fn test_pr_event_published_removes_nostr_ref_at_incorrect_commit( |
| 1466 | client: &AuditClient, | 1741 | client: &AuditClient, |
| 1467 | relay_domain: &str, | 1742 | relay_domain: &str, |
| 1468 | ) -> TestResult { | 1743 | ) -> TestResult { |
| 1469 | let test_name = "test_pr_event_published_removes_nostr_ref_at_incorrect_commit"; | 1744 | let test_name = "test_pr_event_published_removes_nostr_ref_at_incorrect_commit"; |
| 1470 | let desc = "Publishing PR event should trigger cleanup of incorrect refs (NOT_IMPLEMENTED)"; | 1745 | let desc = "Publishing PR event should trigger cleanup of incorrect refs"; |
| 1471 | let ctx = TestContext::new(client); | 1746 | let ctx = TestContext::new(client); |
| 1472 | 1747 | ||
| 1473 | // Setup: wrong commit already pushed to refs/nostr/<pr-event-id> | 1748 | // Setup: wrong commit already pushed to refs/nostr/<pr-event-id> |
| @@ -1479,7 +1754,7 @@ impl PushAuthorizationTests { | |||
| 1479 | }; | 1754 | }; |
| 1480 | 1755 | ||
| 1481 | // NOW publish the PR event - this should trigger cleanup validation | 1756 | // NOW publish the PR event - this should trigger cleanup validation |
| 1482 | if let Err(e) = publish_pr_event_and_wait(&ctx).await { | 1757 | if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await { |
| 1483 | setup.cleanup(); | 1758 | setup.cleanup(); |
| 1484 | return TestResult::new(test_name, "GRASP-01", desc).fail(&e); | 1759 | return TestResult::new(test_name, "GRASP-01", desc).fail(&e); |
| 1485 | } | 1760 | } |
| @@ -1496,12 +1771,16 @@ impl PushAuthorizationTests { | |||
| 1496 | 1771 | ||
| 1497 | setup.cleanup(); | 1772 | setup.cleanup(); |
| 1498 | 1773 | ||
| 1499 | // Document current behavior: relay doesn't implement automatic cleanup yet | 1774 | // Ref should be deleted since the pushed commit doesn't match the PR event's `c` tag |
| 1500 | TestResult::new(test_name, "GRASP-01", desc).fail(&format!( | 1775 | if refs_exist { |
| 1501 | "NOT_IMPLEMENTED: Relay should delete refs/nostr/<event-id> when PR event is published \ | 1776 | TestResult::new(test_name, "GRASP-01", desc).fail(format!( |
| 1502 | with non-matching commit. Currently ref still exists: {}. This requires relay-side validation logic.", | 1777 | "Expected refs/nostr/{} to be deleted when PR event published with non-matching commit, \ |
| 1503 | refs_exist | 1778 | but the ref still exists. The relay should delete refs that don't match the event's `c` tag.", |
| 1504 | )) | 1779 | setup.pr_event_id |
| 1780 | )) | ||
| 1781 | } else { | ||
| 1782 | TestResult::new(test_name, "GRASP-01", desc).pass() | ||
| 1783 | } | ||
| 1505 | } | 1784 | } |
| 1506 | 1785 | ||
| 1507 | /// Test 3: Push wrong commit to refs/nostr/<pr-event-id> AFTER PR event exists | 1786 | /// Test 3: Push wrong commit to refs/nostr/<pr-event-id> AFTER PR event exists |
| @@ -1528,7 +1807,7 @@ impl PushAuthorizationTests { | |||
| 1528 | }; | 1807 | }; |
| 1529 | 1808 | ||
| 1530 | // Publish PR event FIRST (before our test push) | 1809 | // Publish PR event FIRST (before our test push) |
| 1531 | if let Err(e) = publish_pr_event_and_wait(&ctx).await { | 1810 | if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await { |
| 1532 | setup.cleanup(); | 1811 | setup.cleanup(); |
| 1533 | return TestResult::new(test_name, "GRASP-01", desc).fail(&e); | 1812 | return TestResult::new(test_name, "GRASP-01", desc).fail(&e); |
| 1534 | } | 1813 | } |
| @@ -1577,7 +1856,7 @@ impl PushAuthorizationTests { | |||
| 1577 | }; | 1856 | }; |
| 1578 | 1857 | ||
| 1579 | // Publish PR event FIRST | 1858 | // Publish PR event FIRST |
| 1580 | if let Err(e) = publish_pr_event_and_wait(&ctx).await { | 1859 | if let Err(e) = publish_pr_event_and_wait(&ctx, &setup.pr_event).await { |
| 1581 | setup.cleanup(); | 1860 | setup.cleanup(); |
| 1582 | return TestResult::new(test_name, "GRASP-01", desc).fail(&e); | 1861 | return TestResult::new(test_name, "GRASP-01", desc).fail(&e); |
| 1583 | } | 1862 | } |
| @@ -1626,9 +1905,9 @@ mod tests { | |||
| 1626 | /// Run with: cd grasp-audit && nix develop -c cargo test --lib test_pr_test_commit_hash_discovery -- --nocapture | 1905 | /// Run with: cd grasp-audit && nix develop -c cargo test --lib test_pr_test_commit_hash_discovery -- --nocapture |
| 1627 | #[test] | 1906 | #[test] |
| 1628 | fn test_pr_test_commit_hash_discovery() { | 1907 | fn test_pr_test_commit_hash_discovery() { |
| 1908 | use std::fs; | ||
| 1629 | use std::process::Command; | 1909 | use std::process::Command; |
| 1630 | use tempfile::TempDir; | 1910 | use tempfile::TempDir; |
| 1631 | use std::fs; | ||
| 1632 | 1911 | ||
| 1633 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); | 1912 | let temp_dir = TempDir::new().expect("Failed to create temp dir"); |
| 1634 | let path = temp_dir.path(); | 1913 | let path = temp_dir.path(); |
| @@ -1639,7 +1918,11 @@ mod tests { | |||
| 1639 | .current_dir(path) | 1918 | .current_dir(path) |
| 1640 | .output() | 1919 | .output() |
| 1641 | .expect("Failed to init git"); | 1920 | .expect("Failed to init git"); |
| 1642 | assert!(output.status.success(), "git init failed: {:?}", String::from_utf8_lossy(&output.stderr)); | 1921 | assert!( |
| 1922 | output.status.success(), | ||
| 1923 | "git init failed: {:?}", | ||
| 1924 | String::from_utf8_lossy(&output.stderr) | ||
| 1925 | ); | ||
| 1643 | 1926 | ||
| 1644 | // Configure git user - use PR Test Author identity | 1927 | // Configure git user - use PR Test Author identity |
| 1645 | let output = Command::new("git") | 1928 | let output = Command::new("git") |
| @@ -1666,21 +1949,31 @@ mod tests { | |||
| 1666 | .current_dir(path) | 1949 | .current_dir(path) |
| 1667 | .output() | 1950 | .output() |
| 1668 | .expect("git add failed"); | 1951 | .expect("git add failed"); |
| 1669 | assert!(output.status.success(), "git add failed: {:?}", String::from_utf8_lossy(&output.stderr)); | 1952 | assert!( |
| 1953 | output.status.success(), | ||
| 1954 | "git add failed: {:?}", | ||
| 1955 | String::from_utf8_lossy(&output.stderr) | ||
| 1956 | ); | ||
| 1670 | 1957 | ||
| 1671 | // Create deterministic commit with fixed dates and GPG disabled | 1958 | // Create deterministic commit with fixed dates and GPG disabled |
| 1672 | let output = Command::new("git") | 1959 | let output = Command::new("git") |
| 1673 | .args([ | 1960 | .args([ |
| 1674 | "-c", "commit.gpgsign=false", | 1961 | "-c", |
| 1962 | "commit.gpgsign=false", | ||
| 1675 | "commit", | 1963 | "commit", |
| 1676 | "-m", "PR test deterministic commit", | 1964 | "-m", |
| 1965 | "PR test deterministic commit", | ||
| 1677 | ]) | 1966 | ]) |
| 1678 | .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") | 1967 | .env("GIT_AUTHOR_DATE", "2024-01-01T00:00:00Z") |
| 1679 | .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") | 1968 | .env("GIT_COMMITTER_DATE", "2024-01-01T00:00:00Z") |
| 1680 | .current_dir(path) | 1969 | .current_dir(path) |
| 1681 | .output() | 1970 | .output() |
| 1682 | .expect("git commit failed"); | 1971 | .expect("git commit failed"); |
| 1683 | assert!(output.status.success(), "git commit failed: {:?}", String::from_utf8_lossy(&output.stderr)); | 1972 | assert!( |
| 1973 | output.status.success(), | ||
| 1974 | "git commit failed: {:?}", | ||
| 1975 | String::from_utf8_lossy(&output.stderr) | ||
| 1976 | ); | ||
| 1684 | 1977 | ||
| 1685 | // Get the commit hash | 1978 | // Get the commit hash |
| 1686 | let output = Command::new("git") | 1979 | let output = Command::new("git") |
| @@ -1688,7 +1981,11 @@ mod tests { | |||
| 1688 | .current_dir(path) | 1981 | .current_dir(path) |
| 1689 | .output() | 1982 | .output() |
| 1690 | .expect("git rev-parse failed"); | 1983 | .expect("git rev-parse failed"); |
| 1691 | assert!(output.status.success(), "git rev-parse failed: {:?}", String::from_utf8_lossy(&output.stderr)); | 1984 | assert!( |
| 1985 | output.status.success(), | ||
| 1986 | "git rev-parse failed: {:?}", | ||
| 1987 | String::from_utf8_lossy(&output.stderr) | ||
| 1988 | ); | ||
| 1692 | 1989 | ||
| 1693 | let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); | 1990 | let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 1694 | 1991 | ||
| @@ -1698,7 +1995,10 @@ mod tests { | |||
| 1698 | 1995 | ||
| 1699 | // Verify we got a valid 40-character hex hash | 1996 | // Verify we got a valid 40-character hex hash |
| 1700 | assert_eq!(hash.len(), 40, "Hash should be 40 hex chars, got: {}", hash); | 1997 | assert_eq!(hash.len(), 40, "Hash should be 40 hex chars, got: {}", hash); |
| 1701 | assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "Hash should be hex chars only"); | 1998 | assert!( |
| 1999 | hash.chars().all(|c| c.is_ascii_hexdigit()), | ||
| 2000 | "Hash should be hex chars only" | ||
| 2001 | ); | ||
| 1702 | 2002 | ||
| 1703 | // If the constant is not PLACEHOLDER, verify it matches | 2003 | // If the constant is not PLACEHOLDER, verify it matches |
| 1704 | if PR_TEST_COMMIT_HASH != "PLACEHOLDER" { | 2004 | if PR_TEST_COMMIT_HASH != "PLACEHOLDER" { |
| @@ -1709,4 +2009,4 @@ mod tests { | |||
| 1709 | ); | 2009 | ); |
| 1710 | } | 2010 | } |
| 1711 | } | 2011 | } |
| 1712 | } \ No newline at end of file | 2012 | } |
diff --git a/src/git/authorization.rs b/src/git/authorization.rs index bb3bd01..3b0e759 100644 --- a/src/git/authorization.rs +++ b/src/git/authorization.rs | |||
| @@ -35,7 +35,8 @@ use std::sync::Arc; | |||
| 35 | use tracing::debug; | 35 | use tracing::debug; |
| 36 | 36 | ||
| 37 | use crate::nostr::events::{ | 37 | use crate::nostr::events::{ |
| 38 | RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, | 38 | RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, |
| 39 | KIND_REPOSITORY_STATE, | ||
| 39 | }; | 40 | }; |
| 40 | 41 | ||
| 41 | /// Repository data fetched from the database | 42 | /// Repository data fetched from the database |
| @@ -172,9 +173,9 @@ fn get_maintainers_recursive( | |||
| 172 | checked.insert(pubkey.to_string()); // Mark as checked | 173 | checked.insert(pubkey.to_string()); // Mark as checked |
| 173 | 174 | ||
| 174 | // Find the announcement event for this pubkey+identifier | 175 | // Find the announcement event for this pubkey+identifier |
| 175 | let announcement = announcements.iter().find(|a| { | 176 | let announcement = announcements |
| 176 | a.event.pubkey.to_hex() == pubkey && a.identifier == identifier | 177 | .iter() |
| 177 | }); | 178 | .find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier); |
| 178 | 179 | ||
| 179 | let Some(announcement) = announcement else { | 180 | let Some(announcement) = announcement else { |
| 180 | return; // No announcement found for this pubkey | 181 | return; // No announcement found for this pubkey |
| @@ -195,19 +196,19 @@ pub fn collect_all_authorized_maintainers( | |||
| 195 | ) -> HashSet<String> { | 196 | ) -> HashSet<String> { |
| 196 | let by_owner = collect_authorized_maintainers(announcements); | 197 | let by_owner = collect_authorized_maintainers(announcements); |
| 197 | let mut all_authorized = HashSet::new(); | 198 | let mut all_authorized = HashSet::new(); |
| 198 | 199 | ||
| 199 | for maintainers in by_owner.values() { | 200 | for maintainers in by_owner.values() { |
| 200 | for maintainer in maintainers { | 201 | for maintainer in maintainers { |
| 201 | all_authorized.insert(maintainer.clone()); | 202 | all_authorized.insert(maintainer.clone()); |
| 202 | } | 203 | } |
| 203 | } | 204 | } |
| 204 | 205 | ||
| 205 | debug!( | 206 | debug!( |
| 206 | "Collected {} total authorized maintainers from {} owners", | 207 | "Collected {} total authorized maintainers from {} owners", |
| 207 | all_authorized.len(), | 208 | all_authorized.len(), |
| 208 | by_owner.len() | 209 | by_owner.len() |
| 209 | ); | 210 | ); |
| 210 | 211 | ||
| 211 | all_authorized | 212 | all_authorized |
| 212 | } | 213 | } |
| 213 | 214 | ||
| @@ -601,10 +602,7 @@ pub fn validate_push_refs( | |||
| 601 | pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) | 602 | pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) |
| 602 | ) -> Result<()> { | 603 | ) -> Result<()> { |
| 603 | for (old_oid, new_oid, ref_name) in pushed_refs { | 604 | for (old_oid, new_oid, ref_name) in pushed_refs { |
| 604 | debug!( | 605 | debug!("Validating push: {} {} -> {}", ref_name, old_oid, new_oid); |
| 605 | "Validating push: {} {} -> {}", | ||
| 606 | ref_name, old_oid, new_oid | ||
| 607 | ); | ||
| 608 | 606 | ||
| 609 | // Handle branch updates | 607 | // Handle branch updates |
| 610 | if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { | 608 | if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { |
| @@ -657,7 +655,10 @@ pub fn validate_push_refs( | |||
| 657 | )); | 655 | )); |
| 658 | } | 656 | } |
| 659 | // Valid EventId format - allow push (skip state event check) | 657 | // Valid EventId format - allow push (skip state event check) |
| 660 | debug!("refs/nostr/{} push authorized (valid EventId)", event_id_str); | 658 | debug!( |
| 659 | "refs/nostr/{} push authorized (valid EventId)", | ||
| 660 | event_id_str | ||
| 661 | ); | ||
| 661 | continue; // Skip the rest of ref validation for this ref | 662 | continue; // Skip the rest of ref validation for this ref |
| 662 | } else { | 663 | } else { |
| 663 | return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name)); | 664 | return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name)); |
| @@ -805,6 +806,119 @@ pub fn npub_to_pubkey(npub: &str) -> Result<String> { | |||
| 805 | Ok(pk.to_hex()) | 806 | Ok(pk.to_hex()) |
| 806 | } | 807 | } |
| 807 | 808 | ||
| 809 | /// Fetch an event by ID from the database and extract the `c` tag commit hash | ||
| 810 | /// | ||
| 811 | /// This is used for validating pushes to refs/nostr/<event-id>. Per GRASP-01, | ||
| 812 | /// if a PR or PR Update event with this ID exists in the database, the pushed | ||
| 813 | /// commit must match the commit in the event's `c` tag. | ||
| 814 | /// | ||
| 815 | /// # Returns | ||
| 816 | /// - `Ok(Some(commit))` if the event exists and has a valid `c` tag | ||
| 817 | /// - `Ok(None)` if the event doesn't exist (push should be allowed) | ||
| 818 | /// - `Err(_)` on database errors | ||
| 819 | pub async fn get_event_commit_tag( | ||
| 820 | database: &Arc<MemoryDatabase>, | ||
| 821 | event_id: &EventId, | ||
| 822 | ) -> Result<Option<String>> { | ||
| 823 | // Query for PR (1618) and PR Update (1619) events with this ID | ||
| 824 | let filter = Filter::new() | ||
| 825 | .ids([*event_id]) | ||
| 826 | .kinds([Kind::from(KIND_PR), Kind::from(KIND_PR_UPDATE)]); | ||
| 827 | |||
| 828 | let events: Vec<Event> = database | ||
| 829 | .query(filter) | ||
| 830 | .await | ||
| 831 | .map_err(|e| anyhow!("Database query failed: {}", e))? | ||
| 832 | .into_iter() | ||
| 833 | .collect(); | ||
| 834 | |||
| 835 | if events.is_empty() { | ||
| 836 | debug!("No PR/PR Update event found with ID {}", event_id); | ||
| 837 | return Ok(None); | ||
| 838 | } | ||
| 839 | |||
| 840 | // Get the first (should be only) event | ||
| 841 | let event = &events[0]; | ||
| 842 | |||
| 843 | // Extract the `c` tag (commit hash) | ||
| 844 | // Per NIP-34, PR events have a `c` tag with the head commit | ||
| 845 | let commit = event | ||
| 846 | .tags | ||
| 847 | .iter() | ||
| 848 | .find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("c")) | ||
| 849 | .and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string())); | ||
| 850 | |||
| 851 | debug!( | ||
| 852 | "Found PR event {} with commit tag: {:?}", | ||
| 853 | event_id, | ||
| 854 | commit.as_ref() | ||
| 855 | ); | ||
| 856 | |||
| 857 | Ok(commit) | ||
| 858 | } | ||
| 859 | |||
| 860 | /// Validate refs/nostr/ pushes against existing PR/PR Update events | ||
| 861 | /// | ||
| 862 | /// For each ref being pushed to refs/nostr/<event-id>: | ||
| 863 | /// 1. Validate the event ID format (error if invalid) | ||
| 864 | /// 2. Check if a corresponding event exists in the database | ||
| 865 | /// 3. If event exists, verify the pushed commit matches the `c` tag | ||
| 866 | /// | ||
| 867 | /// # Arguments | ||
| 868 | /// * `database` - The nostr database to query | ||
| 869 | /// * `pushed_refs` - List of (old_oid, new_oid, ref_name) tuples | ||
| 870 | /// | ||
| 871 | /// # Returns | ||
| 872 | /// * `Ok(())` if all refs/nostr/ pushes are valid | ||
| 873 | /// * `Err(_)` if any ref has invalid event ID format or fails commit validation | ||
| 874 | pub async fn validate_nostr_ref_pushes( | ||
| 875 | database: &Arc<MemoryDatabase>, | ||
| 876 | pushed_refs: &[(String, String, String)], | ||
| 877 | ) -> Result<()> { | ||
| 878 | for (_, new_oid, ref_name) in pushed_refs { | ||
| 879 | // Only check refs/nostr/ refs | ||
| 880 | if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { | ||
| 881 | // Parse the event ID - error on invalid format | ||
| 882 | let event_id = EventId::parse(event_id_str).map_err(|_| { | ||
| 883 | anyhow!( | ||
| 884 | "Invalid event ID format '{}' in ref: {}", | ||
| 885 | event_id_str, | ||
| 886 | ref_name | ||
| 887 | ) | ||
| 888 | })?; | ||
| 889 | |||
| 890 | // Check if event exists and get commit tag | ||
| 891 | match get_event_commit_tag(database, &event_id).await? { | ||
| 892 | Some(expected_commit) => { | ||
| 893 | // Event exists - verify commit matches | ||
| 894 | if new_oid != &expected_commit { | ||
| 895 | return Err(anyhow!( | ||
| 896 | "Push to {} rejected: event {} specifies commit {}, but push contains {}", | ||
| 897 | ref_name, | ||
| 898 | event_id_str, | ||
| 899 | expected_commit, | ||
| 900 | new_oid | ||
| 901 | )); | ||
| 902 | } | ||
| 903 | debug!( | ||
| 904 | "Push to {} validated: commit {} matches event's c tag", | ||
| 905 | ref_name, new_oid | ||
| 906 | ); | ||
| 907 | } | ||
| 908 | None => { | ||
| 909 | // No event exists yet - allow push | ||
| 910 | debug!( | ||
| 911 | "Push to {} allowed: no PR/PR Update event with ID {} found yet", | ||
| 912 | ref_name, event_id_str | ||
| 913 | ); | ||
| 914 | } | ||
| 915 | } | ||
| 916 | } | ||
| 917 | } | ||
| 918 | |||
| 919 | Ok(()) | ||
| 920 | } | ||
| 921 | |||
| 808 | #[cfg(test)] | 922 | #[cfg(test)] |
| 809 | mod tests { | 923 | mod tests { |
| 810 | use super::*; | 924 | use super::*; |
| @@ -920,7 +1034,7 @@ mod tests { | |||
| 920 | let eve = create_test_keys(); // Not authorized | 1034 | let eve = create_test_keys(); // Not authorized |
| 921 | let identifier = "test-repo"; | 1035 | let identifier = "test-repo"; |
| 922 | 1036 | ||
| 923 | // Alice lists Bob as maintainer | 1037 | // Alice lists Bob as maintainer |
| 924 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); | 1038 | let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); |
| 925 | 1039 | ||
| 926 | let events = vec![alice_announcement]; | 1040 | let events = vec![alice_announcement]; |
| @@ -1084,4 +1198,4 @@ mod tests { | |||
| 1084 | let back_to_hex = npub_to_pubkey(&npub).unwrap(); | 1198 | let back_to_hex = npub_to_pubkey(&npub).unwrap(); |
| 1085 | assert_eq!(hex, back_to_hex); | 1199 | assert_eq!(hex, back_to_hex); |
| 1086 | } | 1200 | } |
| 1087 | } \ No newline at end of file | 1201 | } |
diff --git a/src/git/handlers.rs b/src/git/handlers.rs index 23d4b5b..00f2449 100644 --- a/src/git/handlers.rs +++ b/src/git/handlers.rs | |||
| @@ -2,17 +2,18 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! This module implements the HTTP handlers for Git Smart HTTP protocol. | 3 | //! This module implements the HTTP handlers for Git Smart HTTP protocol. |
| 4 | 4 | ||
| 5 | use std::path::PathBuf; | ||
| 6 | use std::sync::Arc; | ||
| 7 | use hyper::{body::Bytes, Response, StatusCode}; | ||
| 8 | use http_body_util::Full; | 5 | use http_body_util::Full; |
| 6 | use hyper::{body::Bytes, Response, StatusCode}; | ||
| 9 | use nostr_relay_builder::prelude::MemoryDatabase; | 7 | use nostr_relay_builder::prelude::MemoryDatabase; |
| 10 | use nostr_sdk::EventId; | 8 | use nostr_sdk::EventId; |
| 9 | use std::path::PathBuf; | ||
| 10 | use std::sync::Arc; | ||
| 11 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; | 11 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; |
| 12 | use tracing::{debug, error, info, warn}; | 12 | use tracing::{debug, error, info, warn}; |
| 13 | 13 | ||
| 14 | use super::authorization::{ | 14 | use super::authorization::{ |
| 15 | get_authorization_for_owner, parse_pushed_refs, validate_push_refs, AuthorizationResult, | 15 | get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs, |
| 16 | AuthorizationResult, | ||
| 16 | }; | 17 | }; |
| 17 | use super::protocol::{GitService, PktLine}; | 18 | use super::protocol::{GitService, PktLine}; |
| 18 | use super::subprocess::GitSubprocess; | 19 | use super::subprocess::GitSubprocess; |
| @@ -27,7 +28,10 @@ pub async fn handle_info_refs( | |||
| 27 | repo_path: PathBuf, | 28 | repo_path: PathBuf, |
| 28 | service: GitService, | 29 | service: GitService, |
| 29 | ) -> Result<Response<Full<Bytes>>, GitError> { | 30 | ) -> Result<Response<Full<Bytes>>, GitError> { |
| 30 | debug!("Handling info/refs for {:?} with service {:?}", repo_path, service); | 31 | debug!( |
| 32 | "Handling info/refs for {:?} with service {:?}", | ||
| 33 | repo_path, service | ||
| 34 | ); | ||
| 31 | 35 | ||
| 32 | // Check if repository exists | 36 | // Check if repository exists |
| 33 | if !repo_path.exists() { | 37 | if !repo_path.exists() { |
| @@ -36,55 +40,54 @@ pub async fn handle_info_refs( | |||
| 36 | } | 40 | } |
| 37 | 41 | ||
| 38 | // Spawn git with --advertise-refs | 42 | // Spawn git with --advertise-refs |
| 39 | let mut git = GitSubprocess::spawn(service, &repo_path, true) | 43 | let mut git = GitSubprocess::spawn(service, &repo_path, true).map_err(|e| { |
| 40 | .map_err(|e| { | 44 | error!("Failed to spawn git process: {}", e); |
| 41 | error!("Failed to spawn git process: {}", e); | 45 | GitError::ProcessSpawnFailed(e) |
| 42 | GitError::ProcessSpawnFailed(e) | 46 | })?; |
| 43 | })?; | ||
| 44 | 47 | ||
| 45 | // Read the output from git | 48 | // Read the output from git |
| 46 | let mut output = Vec::new(); | 49 | let mut output = Vec::new(); |
| 47 | let mut stderr_output = Vec::new(); | 50 | let mut stderr_output = Vec::new(); |
| 48 | 51 | ||
| 49 | if let Some(stdout) = git.take_stdout() { | 52 | if let Some(stdout) = git.take_stdout() { |
| 50 | let mut stdout = stdout; | 53 | let mut stdout = stdout; |
| 51 | stdout.read_to_end(&mut output).await | 54 | stdout.read_to_end(&mut output).await.map_err(|e| { |
| 52 | .map_err(|e| { | 55 | error!("Failed to read git output: {}", e); |
| 53 | error!("Failed to read git output: {}", e); | 56 | GitError::IoError(e) |
| 54 | GitError::IoError(e) | 57 | })?; |
| 55 | })?; | ||
| 56 | } | 58 | } |
| 57 | 59 | ||
| 58 | if let Some(stderr) = git.take_stderr() { | 60 | if let Some(stderr) = git.take_stderr() { |
| 59 | let mut stderr = stderr; | 61 | let mut stderr = stderr; |
| 60 | stderr.read_to_end(&mut stderr_output).await | 62 | stderr.read_to_end(&mut stderr_output).await.map_err(|e| { |
| 61 | .map_err(|e| { | 63 | error!("Failed to read git stderr: {}", e); |
| 62 | error!("Failed to read git stderr: {}", e); | 64 | GitError::IoError(e) |
| 63 | GitError::IoError(e) | 65 | })?; |
| 64 | })?; | ||
| 65 | } | 66 | } |
| 66 | 67 | ||
| 67 | // Wait for process to complete | 68 | // Wait for process to complete |
| 68 | let status = git.wait().await | 69 | let status = git.wait().await.map_err(|e| { |
| 69 | .map_err(|e| { | 70 | error!("Failed to wait for git process: {}", e); |
| 70 | error!("Failed to wait for git process: {}", e); | 71 | GitError::IoError(e) |
| 71 | GitError::IoError(e) | 72 | })?; |
| 72 | })?; | ||
| 73 | 73 | ||
| 74 | if !status.success() { | 74 | if !status.success() { |
| 75 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 75 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| 76 | error!("Git process failed with status: {:?}, stderr: {}", status, stderr_str); | 76 | error!( |
| 77 | "Git process failed with status: {:?}, stderr: {}", | ||
| 78 | status, stderr_str | ||
| 79 | ); | ||
| 77 | return Err(GitError::GitFailed(status.code())); | 80 | return Err(GitError::GitFailed(status.code())); |
| 78 | } | 81 | } |
| 79 | 82 | ||
| 80 | // Build response with pkt-line header | 83 | // Build response with pkt-line header |
| 81 | let mut response_body = Vec::new(); | 84 | let mut response_body = Vec::new(); |
| 82 | 85 | ||
| 83 | // First line: service advertisement | 86 | // First line: service advertisement |
| 84 | let service_line = format!("# service={}\n", service.as_str()); | 87 | let service_line = format!("# service={}\n", service.as_str()); |
| 85 | response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); | 88 | response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); |
| 86 | response_body.extend_from_slice(&PktLine::flush().encode()); | 89 | response_body.extend_from_slice(&PktLine::flush().encode()); |
| 87 | 90 | ||
| 88 | // Then the git output | 91 | // Then the git output |
| 89 | response_body.extend_from_slice(&output); | 92 | response_body.extend_from_slice(&output); |
| 90 | 93 | ||
| @@ -113,7 +116,9 @@ pub async fn handle_upload_pack( | |||
| 113 | 116 | ||
| 114 | // Write request to git's stdin | 117 | // Write request to git's stdin |
| 115 | if let Some(mut stdin) = git.take_stdin() { | 118 | if let Some(mut stdin) = git.take_stdin() { |
| 116 | stdin.write_all(&request_body).await | 119 | stdin |
| 120 | .write_all(&request_body) | ||
| 121 | .await | ||
| 117 | .map_err(GitError::IoError)?; | 122 | .map_err(GitError::IoError)?; |
| 118 | // Close stdin to signal end of input | 123 | // Close stdin to signal end of input |
| 119 | drop(stdin); | 124 | drop(stdin); |
| @@ -122,22 +127,25 @@ pub async fn handle_upload_pack( | |||
| 122 | // Read response from git's stdout | 127 | // Read response from git's stdout |
| 123 | let mut output = Vec::new(); | 128 | let mut output = Vec::new(); |
| 124 | let mut stderr_output = Vec::new(); | 129 | let mut stderr_output = Vec::new(); |
| 125 | 130 | ||
| 126 | if let Some(stdout) = git.take_stdout() { | 131 | if let Some(stdout) = git.take_stdout() { |
| 127 | let mut stdout = stdout; | 132 | let mut stdout = stdout; |
| 128 | stdout.read_to_end(&mut output).await | 133 | stdout |
| 134 | .read_to_end(&mut output) | ||
| 135 | .await | ||
| 129 | .map_err(GitError::IoError)?; | 136 | .map_err(GitError::IoError)?; |
| 130 | } | 137 | } |
| 131 | 138 | ||
| 132 | if let Some(stderr) = git.take_stderr() { | 139 | if let Some(stderr) = git.take_stderr() { |
| 133 | let mut stderr = stderr; | 140 | let mut stderr = stderr; |
| 134 | stderr.read_to_end(&mut stderr_output).await | 141 | stderr |
| 142 | .read_to_end(&mut stderr_output) | ||
| 143 | .await | ||
| 135 | .map_err(GitError::IoError)?; | 144 | .map_err(GitError::IoError)?; |
| 136 | } | 145 | } |
| 137 | 146 | ||
| 138 | // Wait for process | 147 | // Wait for process |
| 139 | let status = git.wait().await | 148 | let status = git.wait().await.map_err(GitError::IoError)?; |
| 140 | .map_err(GitError::IoError)?; | ||
| 141 | 149 | ||
| 142 | if !status.success() { | 150 | if !status.success() { |
| 143 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 151 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| @@ -194,10 +202,7 @@ pub async fn handle_receive_pack( | |||
| 194 | match authorize_push(db, identifier, owner_pubkey, &request_body).await { | 202 | match authorize_push(db, identifier, owner_pubkey, &request_body).await { |
| 195 | Ok(auth_result) => { | 203 | Ok(auth_result) => { |
| 196 | if !auth_result.authorized { | 204 | if !auth_result.authorized { |
| 197 | warn!( | 205 | warn!("Push rejected for {}: {}", identifier, auth_result.reason); |
| 198 | "Push rejected for {}: {}", | ||
| 199 | identifier, auth_result.reason | ||
| 200 | ); | ||
| 201 | return Err(GitError::Unauthorized); | 206 | return Err(GitError::Unauthorized); |
| 202 | } | 207 | } |
| 203 | info!( | 208 | info!( |
| @@ -209,10 +214,7 @@ pub async fn handle_receive_pack( | |||
| 209 | authorized_state = auth_result.state; | 214 | authorized_state = auth_result.state; |
| 210 | } | 215 | } |
| 211 | Err(e) => { | 216 | Err(e) => { |
| 212 | warn!( | 217 | warn!("Authorization check failed for {}: {}", identifier, e); |
| 213 | "Authorization check failed for {}: {}", | ||
| 214 | identifier, e | ||
| 215 | ); | ||
| 216 | return Err(GitError::Unauthorized); | 218 | return Err(GitError::Unauthorized); |
| 217 | } | 219 | } |
| 218 | } | 220 | } |
| @@ -226,7 +228,9 @@ pub async fn handle_receive_pack( | |||
| 226 | 228 | ||
| 227 | // Write request to git's stdin | 229 | // Write request to git's stdin |
| 228 | if let Some(mut stdin) = git.take_stdin() { | 230 | if let Some(mut stdin) = git.take_stdin() { |
| 229 | stdin.write_all(&request_body).await | 231 | stdin |
| 232 | .write_all(&request_body) | ||
| 233 | .await | ||
| 230 | .map_err(GitError::IoError)?; | 234 | .map_err(GitError::IoError)?; |
| 231 | drop(stdin); | 235 | drop(stdin); |
| 232 | } | 236 | } |
| @@ -234,22 +238,25 @@ pub async fn handle_receive_pack( | |||
| 234 | // Read response from git's stdout | 238 | // Read response from git's stdout |
| 235 | let mut output = Vec::new(); | 239 | let mut output = Vec::new(); |
| 236 | let mut stderr_output = Vec::new(); | 240 | let mut stderr_output = Vec::new(); |
| 237 | 241 | ||
| 238 | if let Some(stdout) = git.take_stdout() { | 242 | if let Some(stdout) = git.take_stdout() { |
| 239 | let mut stdout = stdout; | 243 | let mut stdout = stdout; |
| 240 | stdout.read_to_end(&mut output).await | 244 | stdout |
| 245 | .read_to_end(&mut output) | ||
| 246 | .await | ||
| 241 | .map_err(GitError::IoError)?; | 247 | .map_err(GitError::IoError)?; |
| 242 | } | 248 | } |
| 243 | 249 | ||
| 244 | if let Some(stderr) = git.take_stderr() { | 250 | if let Some(stderr) = git.take_stderr() { |
| 245 | let mut stderr = stderr; | 251 | let mut stderr = stderr; |
| 246 | stderr.read_to_end(&mut stderr_output).await | 252 | stderr |
| 253 | .read_to_end(&mut stderr_output) | ||
| 254 | .await | ||
| 247 | .map_err(GitError::IoError)?; | 255 | .map_err(GitError::IoError)?; |
| 248 | } | 256 | } |
| 249 | 257 | ||
| 250 | // Wait for process | 258 | // Wait for process |
| 251 | let status = git.wait().await | 259 | let status = git.wait().await.map_err(GitError::IoError)?; |
| 252 | .map_err(GitError::IoError)?; | ||
| 253 | 260 | ||
| 254 | if !status.success() { | 261 | if !status.success() { |
| 255 | let stderr_str = String::from_utf8_lossy(&stderr_output); | 262 | let stderr_str = String::from_utf8_lossy(&stderr_output); |
| @@ -266,10 +273,7 @@ pub async fn handle_receive_pack( | |||
| 266 | if let Some(commit) = state.get_branch_commit(branch_name) { | 273 | if let Some(commit) = state.get_branch_commit(branch_name) { |
| 267 | match try_set_head_if_available(&repo_path, head_ref, commit) { | 274 | match try_set_head_if_available(&repo_path, head_ref, commit) { |
| 268 | Ok(true) => { | 275 | Ok(true) => { |
| 269 | info!( | 276 | info!("Set HEAD to {} after push to {:?}", head_ref, repo_path); |
| 270 | "Set HEAD to {} after push to {:?}", | ||
| 271 | head_ref, repo_path | ||
| 272 | ); | ||
| 273 | } | 277 | } |
| 274 | Ok(false) => { | 278 | Ok(false) => { |
| 275 | debug!( | 279 | debug!( |
| @@ -278,10 +282,7 @@ pub async fn handle_receive_pack( | |||
| 278 | ); | 282 | ); |
| 279 | } | 283 | } |
| 280 | Err(e) => { | 284 | Err(e) => { |
| 281 | warn!( | 285 | warn!("Failed to set HEAD after push: {}", e); |
| 282 | "Failed to set HEAD after push: {}", | ||
| 283 | e | ||
| 284 | ); | ||
| 285 | } | 286 | } |
| 286 | } | 287 | } |
| 287 | } | 288 | } |
| @@ -291,7 +292,10 @@ pub async fn handle_receive_pack( | |||
| 291 | 292 | ||
| 292 | Ok(Response::builder() | 293 | Ok(Response::builder() |
| 293 | .status(StatusCode::OK) | 294 | .status(StatusCode::OK) |
| 294 | .header("content-type", GitService::ReceivePack.result_content_type()) | 295 | .header( |
| 296 | "content-type", | ||
| 297 | GitService::ReceivePack.result_content_type(), | ||
| 298 | ) | ||
| 295 | .header("cache-control", "no-cache") | 299 | .header("cache-control", "no-cache") |
| 296 | .body(Full::new(Bytes::from(output))) | 300 | .body(Full::new(Bytes::from(output))) |
| 297 | .unwrap()) | 301 | .unwrap()) |
| @@ -305,6 +309,7 @@ pub async fn handle_receive_pack( | |||
| 305 | /// 3. Collects authorized publishers from that announcement (owner + maintainers) | 309 | /// 3. Collects authorized publishers from that announcement (owner + maintainers) |
| 306 | /// 4. Gets the latest authorized state from those publishers | 310 | /// 4. Gets the latest authorized state from those publishers |
| 307 | /// 5. Validates that pushed refs match the state | 311 | /// 5. Validates that pushed refs match the state |
| 312 | /// 6. Validates refs/nostr/<event-id> has valid event id and if event exists, `c` tag matches ref | ||
| 308 | async fn authorize_push( | 313 | async fn authorize_push( |
| 309 | database: &Arc<MemoryDatabase>, | 314 | database: &Arc<MemoryDatabase>, |
| 310 | identifier: &str, | 315 | identifier: &str, |
| @@ -323,59 +328,79 @@ async fn authorize_push( | |||
| 323 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); | 328 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); |
| 324 | } | 329 | } |
| 325 | 330 | ||
| 326 | // Check if ALL pushed refs are to refs/nostr/ with valid EventId format | 331 | // Separate refs/nostr/ refs from other refs |
| 327 | // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" | 332 | // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" |
| 328 | // These pushes only require EventId format validation, not state validation | 333 | let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs |
| 329 | let all_refs_nostr_valid = !pushed_refs.is_empty() | 334 | .iter() |
| 330 | && pushed_refs.iter().all(|(_, _, ref_name)| { | 335 | .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/")); |
| 331 | if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { | 336 | |
| 332 | // Validate it parses as a valid EventId | 337 | // Validate refs/nostr/ refs if any exist |
| 333 | EventId::parse(event_id_str).is_ok() | 338 | if !nostr_refs.is_empty() { |
| 334 | } else { | 339 | debug!( |
| 335 | false | 340 | "Found {} refs/nostr/ refs - validating against events", |
| 336 | } | 341 | nostr_refs.len() |
| 337 | }); | 342 | ); |
| 338 | 343 | ||
| 339 | if all_refs_nostr_valid { | 344 | // Validate refs/nostr/ pushes: checks event ID format and commit matching |
| 340 | debug!("All refs are refs/nostr/ with valid EventId format - authorized without state check"); | 345 | let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs |
| 341 | // Return success for refs/nostr/ pushes without requiring state | 346 | .into_iter() |
| 347 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) | ||
| 348 | .collect(); | ||
| 349 | if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await { | ||
| 350 | warn!("refs/nostr/ validation failed: {}", e); | ||
| 351 | return Ok(AuthorizationResult::denied(format!( | ||
| 352 | "refs/nostr/ validation failed: {}", | ||
| 353 | e | ||
| 354 | ))); | ||
| 355 | } | ||
| 356 | debug!("refs/nostr/ push validated successfully"); | ||
| 357 | } | ||
| 358 | |||
| 359 | // If only refs/nostr/ refs, we're done - return success | ||
| 360 | if other_refs.is_empty() { | ||
| 361 | debug!("Only refs/nostr/ refs in push - authorization complete"); | ||
| 342 | return Ok(AuthorizationResult { | 362 | return Ok(AuthorizationResult { |
| 343 | authorized: true, | 363 | authorized: true, |
| 344 | reason: "Push to refs/nostr/ with valid EventId format".to_string(), | 364 | reason: "Push to refs/nostr/ validated against events".to_string(), |
| 345 | state: None, | 365 | state: None, |
| 346 | maintainers: vec![], | 366 | maintainers: vec![], |
| 347 | }); | 367 | }); |
| 348 | } | 368 | } |
| 349 | 369 | ||
| 350 | // For non-refs/nostr/ pushes, require state validation as normal | 370 | // For non-refs/nostr/ refs, require state validation |
| 351 | debug!("Non-refs/nostr/ push detected - checking state authorization"); | 371 | debug!( |
| 372 | "Found {} non-refs/nostr/ refs - checking state authorization", | ||
| 373 | other_refs.len() | ||
| 374 | ); | ||
| 352 | let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; | 375 | let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; |
| 353 | 376 | ||
| 354 | if !auth_result.authorized { | 377 | if !auth_result.authorized { |
| 355 | return Ok(auth_result); | 378 | return Ok(auth_result); |
| 356 | } | 379 | } |
| 357 | 380 | ||
| 358 | // Parse refs from the push request | 381 | // Convert other_refs for validation |
| 359 | let pushed_refs = parse_pushed_refs(request_body); | 382 | let other_refs_owned: Vec<(String, String, String)> = other_refs |
| 360 | debug!("Parsed {} refs from push request", pushed_refs.len()); | 383 | .into_iter() |
| 361 | for (old_oid, new_oid, ref_name) in &pushed_refs { | 384 | .map(|(a, b, c)| (a.clone(), b.clone(), c.clone())) |
| 362 | debug!(" {} {} -> {}", ref_name, old_oid, new_oid); | 385 | .collect(); |
| 363 | } | ||
| 364 | 386 | ||
| 365 | // Validate refs against state | 387 | // Validate non-refs/nostr/ refs against state |
| 366 | if let Some(ref state) = auth_result.state { | 388 | if let Some(ref state) = auth_result.state { |
| 367 | debug!("Validating against state with {} branches", state.branches.len()); | 389 | debug!( |
| 368 | 390 | "Validating against state with {} branches", | |
| 391 | state.branches.len() | ||
| 392 | ); | ||
| 393 | |||
| 369 | // If we have a state event but couldn't parse any refs, reject the push. | 394 | // If we have a state event but couldn't parse any refs, reject the push. |
| 370 | // This protects against parsing failures allowing unauthorized pushes. | 395 | // This protects against parsing failures allowing unauthorized pushes. |
| 371 | if pushed_refs.is_empty() && !state.branches.is_empty() { | 396 | if other_refs_owned.is_empty() && !state.branches.is_empty() { |
| 372 | warn!("No refs parsed from push request but state event has branches - rejecting"); | 397 | warn!("No refs parsed from push request but state event has branches - rejecting"); |
| 373 | return Ok(AuthorizationResult::denied( | 398 | return Ok(AuthorizationResult::denied( |
| 374 | "Failed to parse refs from push request - cannot validate against state" | 399 | "Failed to parse refs from push request - cannot validate against state", |
| 375 | )); | 400 | )); |
| 376 | } | 401 | } |
| 377 | 402 | ||
| 378 | if let Err(e) = validate_push_refs(state, &pushed_refs) { | 403 | if let Err(e) = validate_push_refs(state, &other_refs_owned) { |
| 379 | warn!("Ref validation failed: {}", e); | 404 | warn!("Ref validation failed: {}", e); |
| 380 | return Ok(AuthorizationResult::denied(format!( | 405 | return Ok(AuthorizationResult::denied(format!( |
| 381 | "Ref validation failed: {}", | 406 | "Ref validation failed: {}", |
| @@ -423,4 +448,4 @@ impl GitError { | |||
| 423 | _ => StatusCode::INTERNAL_SERVER_ERROR, | 448 | _ => StatusCode::INTERNAL_SERVER_ERROR, |
| 424 | } | 449 | } |
| 425 | } | 450 | } |
| 426 | } \ No newline at end of file | 451 | } |
diff --git a/src/git/mod.rs b/src/git/mod.rs index 076e211..494f8b9 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs | |||
| @@ -40,7 +40,7 @@ use tracing::{debug, info}; | |||
| 40 | pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { | 40 | pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { |
| 41 | // Remove .git suffix if present | 41 | // Remove .git suffix if present |
| 42 | let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); | 42 | let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); |
| 43 | 43 | ||
| 44 | PathBuf::from(git_data_path) | 44 | PathBuf::from(git_data_path) |
| 45 | .join(npub) | 45 | .join(npub) |
| 46 | .join(format!("{}.git", identifier)) | 46 | .join(format!("{}.git", identifier)) |
| @@ -89,7 +89,10 @@ pub fn commit_exists(repo_path: &Path, commit_hash: &str) -> bool { | |||
| 89 | pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { | 89 | pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { |
| 90 | // Validate the ref format | 90 | // Validate the ref format |
| 91 | if !head_ref.starts_with("refs/heads/") { | 91 | if !head_ref.starts_with("refs/heads/") { |
| 92 | return Err(format!("Invalid HEAD ref: {} (must start with refs/heads/)", head_ref)); | 92 | return Err(format!( |
| 93 | "Invalid HEAD ref: {} (must start with refs/heads/)", | ||
| 94 | head_ref | ||
| 95 | )); | ||
| 93 | } | 96 | } |
| 94 | 97 | ||
| 95 | debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); | 98 | debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); |
| @@ -130,7 +133,10 @@ pub fn try_set_head_if_available( | |||
| 130 | ) -> Result<bool, String> { | 133 | ) -> Result<bool, String> { |
| 131 | // Check if repository exists | 134 | // Check if repository exists |
| 132 | if !repo_path.exists() { | 135 | if !repo_path.exists() { |
| 133 | debug!("Repository not found at {}, cannot set HEAD", repo_path.display()); | 136 | debug!( |
| 137 | "Repository not found at {}, cannot set HEAD", | ||
| 138 | repo_path.display() | ||
| 139 | ); | ||
| 134 | return Ok(false); | 140 | return Ok(false); |
| 135 | } | 141 | } |
| 136 | 142 | ||
| @@ -149,6 +155,115 @@ pub fn try_set_head_if_available( | |||
| 149 | Ok(true) | 155 | Ok(true) |
| 150 | } | 156 | } |
| 151 | 157 | ||
| 158 | /// Get the commit hash that a ref points to | ||
| 159 | /// | ||
| 160 | /// # Arguments | ||
| 161 | /// * `repo_path` - Path to the bare git repository | ||
| 162 | /// * `ref_name` - The ref name (e.g., "refs/nostr/<event-id>") | ||
| 163 | /// | ||
| 164 | /// # Returns | ||
| 165 | /// Some(commit_hash) if the ref exists, None otherwise | ||
| 166 | pub fn get_ref_commit(repo_path: &Path, ref_name: &str) -> Option<String> { | ||
| 167 | let output = Command::new("git") | ||
| 168 | .args(["rev-parse", ref_name]) | ||
| 169 | .current_dir(repo_path) | ||
| 170 | .output() | ||
| 171 | .ok()?; | ||
| 172 | |||
| 173 | if output.status.success() { | ||
| 174 | Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) | ||
| 175 | } else { | ||
| 176 | None | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Delete a git ref from the repository | ||
| 181 | /// | ||
| 182 | /// # Arguments | ||
| 183 | /// * `repo_path` - Path to the bare git repository | ||
| 184 | /// * `ref_name` - The ref name to delete (e.g., "refs/nostr/<event-id>") | ||
| 185 | /// | ||
| 186 | /// # Returns | ||
| 187 | /// Ok(()) if successful, Err with error message otherwise | ||
| 188 | pub fn delete_ref(repo_path: &Path, ref_name: &str) -> Result<(), String> { | ||
| 189 | debug!("Deleting ref {} from {}", ref_name, repo_path.display()); | ||
| 190 | |||
| 191 | let output = Command::new("git") | ||
| 192 | .args(["update-ref", "-d", ref_name]) | ||
| 193 | .current_dir(repo_path) | ||
| 194 | .output() | ||
| 195 | .map_err(|e| format!("Failed to execute git update-ref: {}", e))?; | ||
| 196 | |||
| 197 | if !output.status.success() { | ||
| 198 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 199 | return Err(format!("git update-ref -d failed: {}", stderr)); | ||
| 200 | } | ||
| 201 | |||
| 202 | info!("Deleted ref {} from {}", ref_name, repo_path.display()); | ||
| 203 | Ok(()) | ||
| 204 | } | ||
| 205 | |||
| 206 | /// Validate refs/nostr/<event-id> ref against expected commit | ||
| 207 | /// | ||
| 208 | /// If the ref exists but points to a different commit than expected, | ||
| 209 | /// the ref is deleted. This is called when a PR event is received to | ||
| 210 | /// ensure refs/nostr refs are consistent with their corresponding events. | ||
| 211 | /// | ||
| 212 | /// # Arguments | ||
| 213 | /// * `repo_path` - Path to the bare git repository | ||
| 214 | /// * `event_id` - The event ID (hex string) | ||
| 215 | /// * `expected_commit` - The commit hash from the event's `c` tag | ||
| 216 | /// | ||
| 217 | /// # Returns | ||
| 218 | /// Ok(true) if ref was deleted (mismatch), Ok(false) if no action taken, Err on failure | ||
| 219 | pub fn validate_nostr_ref( | ||
| 220 | repo_path: &Path, | ||
| 221 | event_id: &str, | ||
| 222 | expected_commit: &str, | ||
| 223 | ) -> Result<bool, String> { | ||
| 224 | let ref_name = format!("refs/nostr/{}", event_id); | ||
| 225 | |||
| 226 | // Check if repository exists | ||
| 227 | if !repo_path.exists() { | ||
| 228 | debug!( | ||
| 229 | "Repository not found at {}, skipping ref validation", | ||
| 230 | repo_path.display() | ||
| 231 | ); | ||
| 232 | return Ok(false); | ||
| 233 | } | ||
| 234 | |||
| 235 | // Check if the ref exists | ||
| 236 | let current_commit = match get_ref_commit(repo_path, &ref_name) { | ||
| 237 | Some(commit) => commit, | ||
| 238 | None => { | ||
| 239 | debug!("Ref {} does not exist in {}", ref_name, repo_path.display()); | ||
| 240 | return Ok(false); | ||
| 241 | } | ||
| 242 | }; | ||
| 243 | |||
| 244 | // Compare commits | ||
| 245 | if current_commit == expected_commit { | ||
| 246 | debug!( | ||
| 247 | "Ref {} points to correct commit {} in {}", | ||
| 248 | ref_name, | ||
| 249 | expected_commit, | ||
| 250 | repo_path.display() | ||
| 251 | ); | ||
| 252 | return Ok(false); | ||
| 253 | } | ||
| 254 | |||
| 255 | // Commit mismatch - delete the ref | ||
| 256 | info!( | ||
| 257 | "Deleting mismatched ref {} in {}: expected {}, found {}", | ||
| 258 | ref_name, | ||
| 259 | repo_path.display(), | ||
| 260 | expected_commit, | ||
| 261 | current_commit | ||
| 262 | ); | ||
| 263 | delete_ref(repo_path, &ref_name)?; | ||
| 264 | Ok(true) | ||
| 265 | } | ||
| 266 | |||
| 152 | /// Get the current HEAD ref from a repository | 267 | /// Get the current HEAD ref from a repository |
| 153 | /// | 268 | /// |
| 154 | /// # Arguments | 269 | /// # Arguments |
| @@ -178,25 +293,25 @@ pub fn get_repository_head(repo_path: &Path) -> Option<String> { | |||
| 178 | pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { | 293 | pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { |
| 179 | // Remove leading slash | 294 | // Remove leading slash |
| 180 | let path = path.strip_prefix('/').unwrap_or(path); | 295 | let path = path.strip_prefix('/').unwrap_or(path); |
| 181 | 296 | ||
| 182 | // Split into components | 297 | // Split into components |
| 183 | let parts: Vec<&str> = path.splitn(3, '/').collect(); | 298 | let parts: Vec<&str> = path.splitn(3, '/').collect(); |
| 184 | 299 | ||
| 185 | if parts.len() < 3 { | 300 | if parts.len() < 3 { |
| 186 | return None; | 301 | return None; |
| 187 | } | 302 | } |
| 188 | 303 | ||
| 189 | let npub = parts[0]; | 304 | let npub = parts[0]; |
| 190 | let repo_part = parts[1]; | 305 | let repo_part = parts[1]; |
| 191 | let subpath = parts[2]; | 306 | let subpath = parts[2]; |
| 192 | 307 | ||
| 193 | // Extract identifier (remove .git suffix if present for the middle part) | 308 | // Extract identifier (remove .git suffix if present for the middle part) |
| 194 | let identifier = if repo_part.ends_with(".git") { | 309 | let identifier = if repo_part.ends_with(".git") { |
| 195 | &repo_part[..repo_part.len() - 4] | 310 | &repo_part[..repo_part.len() - 4] |
| 196 | } else { | 311 | } else { |
| 197 | repo_part | 312 | repo_part |
| 198 | }; | 313 | }; |
| 199 | 314 | ||
| 200 | Some((npub, identifier, subpath)) | 315 | Some((npub, identifier, subpath)) |
| 201 | } | 316 | } |
| 202 | 317 | ||
| @@ -210,13 +325,13 @@ mod tests { | |||
| 210 | fn create_test_repo() -> (TempDir, PathBuf) { | 325 | fn create_test_repo() -> (TempDir, PathBuf) { |
| 211 | let temp_dir = TempDir::new().unwrap(); | 326 | let temp_dir = TempDir::new().unwrap(); |
| 212 | let repo_path = temp_dir.path().join("test.git"); | 327 | let repo_path = temp_dir.path().join("test.git"); |
| 213 | 328 | ||
| 214 | // Initialize bare repository | 329 | // Initialize bare repository |
| 215 | Command::new("git") | 330 | Command::new("git") |
| 216 | .args(["init", "--bare", repo_path.to_str().unwrap()]) | 331 | .args(["init", "--bare", repo_path.to_str().unwrap()]) |
| 217 | .output() | 332 | .output() |
| 218 | .unwrap(); | 333 | .unwrap(); |
| 219 | 334 | ||
| 220 | (temp_dir, repo_path) | 335 | (temp_dir, repo_path) |
| 221 | } | 336 | } |
| 222 | 337 | ||
| @@ -225,19 +340,23 @@ mod tests { | |||
| 225 | let temp_dir = TempDir::new().unwrap(); | 340 | let temp_dir = TempDir::new().unwrap(); |
| 226 | let work_dir = temp_dir.path().join("work"); | 341 | let work_dir = temp_dir.path().join("work"); |
| 227 | let bare_repo = temp_dir.path().join("test.git"); | 342 | let bare_repo = temp_dir.path().join("test.git"); |
| 228 | 343 | ||
| 229 | // Initialize bare repository | 344 | // Initialize bare repository |
| 230 | Command::new("git") | 345 | Command::new("git") |
| 231 | .args(["init", "--bare", bare_repo.to_str().unwrap()]) | 346 | .args(["init", "--bare", "--initial-branch=main", bare_repo.to_str().unwrap()]) |
| 232 | .output() | 347 | .output() |
| 233 | .unwrap(); | 348 | .unwrap(); |
| 234 | 349 | ||
| 235 | // Clone to working directory | 350 | // Clone to working directory |
| 236 | Command::new("git") | 351 | Command::new("git") |
| 237 | .args(["clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap()]) | 352 | .args([ |
| 353 | "clone", | ||
| 354 | bare_repo.to_str().unwrap(), | ||
| 355 | work_dir.to_str().unwrap(), | ||
| 356 | ]) | ||
| 238 | .output() | 357 | .output() |
| 239 | .unwrap(); | 358 | .unwrap(); |
| 240 | 359 | ||
| 241 | // Configure git for commits | 360 | // Configure git for commits |
| 242 | Command::new("git") | 361 | Command::new("git") |
| 243 | .args(["config", "user.email", "test@test.com"]) | 362 | .args(["config", "user.email", "test@test.com"]) |
| @@ -249,7 +368,7 @@ mod tests { | |||
| 249 | .current_dir(&work_dir) | 368 | .current_dir(&work_dir) |
| 250 | .output() | 369 | .output() |
| 251 | .unwrap(); | 370 | .unwrap(); |
| 252 | 371 | ||
| 253 | // Create a file and commit | 372 | // Create a file and commit |
| 254 | fs::write(work_dir.join("README.md"), "# Test").unwrap(); | 373 | fs::write(work_dir.join("README.md"), "# Test").unwrap(); |
| 255 | Command::new("git") | 374 | Command::new("git") |
| @@ -262,7 +381,7 @@ mod tests { | |||
| 262 | .current_dir(&work_dir) | 381 | .current_dir(&work_dir) |
| 263 | .output() | 382 | .output() |
| 264 | .unwrap(); | 383 | .unwrap(); |
| 265 | 384 | ||
| 266 | // Get commit hash | 385 | // Get commit hash |
| 267 | let output = Command::new("git") | 386 | let output = Command::new("git") |
| 268 | .args(["rev-parse", "HEAD"]) | 387 | .args(["rev-parse", "HEAD"]) |
| @@ -270,41 +389,27 @@ mod tests { | |||
| 270 | .output() | 389 | .output() |
| 271 | .unwrap(); | 390 | .unwrap(); |
| 272 | let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); | 391 | let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 273 | 392 | ||
| 274 | // Push to bare repo | 393 | // Push to bare repo |
| 275 | Command::new("git") | 394 | Command::new("git") |
| 276 | .args(["push", "origin", "master"]) | 395 | .args(["push", "origin", "main"]) |
| 277 | .current_dir(&work_dir) | 396 | .current_dir(&work_dir) |
| 278 | .output() | 397 | .output() |
| 279 | .unwrap(); | 398 | .unwrap(); |
| 280 | 399 | ||
| 281 | (temp_dir, bare_repo, commit_hash) | 400 | (temp_dir, bare_repo, commit_hash) |
| 282 | } | 401 | } |
| 283 | 402 | ||
| 284 | #[test] | 403 | #[test] |
| 285 | fn test_resolve_repo_path() { | 404 | fn test_resolve_repo_path() { |
| 286 | let path = resolve_repo_path( | 405 | let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo"); |
| 287 | "/data/git", | 406 | assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); |
| 288 | "npub1abc123", | ||
| 289 | "my-repo" | ||
| 290 | ); | ||
| 291 | assert_eq!( | ||
| 292 | path, | ||
| 293 | PathBuf::from("/data/git/npub1abc123/my-repo.git") | ||
| 294 | ); | ||
| 295 | } | 407 | } |
| 296 | 408 | ||
| 297 | #[test] | 409 | #[test] |
| 298 | fn test_resolve_repo_path_with_git_suffix() { | 410 | fn test_resolve_repo_path_with_git_suffix() { |
| 299 | let path = resolve_repo_path( | 411 | let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo.git"); |
| 300 | "/data/git", | 412 | assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git")); |
| 301 | "npub1abc123", | ||
| 302 | "my-repo.git" | ||
| 303 | ); | ||
| 304 | assert_eq!( | ||
| 305 | path, | ||
| 306 | PathBuf::from("/data/git/npub1abc123/my-repo.git") | ||
| 307 | ); | ||
| 308 | } | 413 | } |
| 309 | 414 | ||
| 310 | #[test] | 415 | #[test] |
| @@ -332,7 +437,10 @@ mod tests { | |||
| 332 | #[test] | 437 | #[test] |
| 333 | fn test_commit_exists_nonexistent() { | 438 | fn test_commit_exists_nonexistent() { |
| 334 | let (_temp_dir, repo_path) = create_test_repo(); | 439 | let (_temp_dir, repo_path) = create_test_repo(); |
| 335 | assert!(!commit_exists(&repo_path, "deadbeef1234567890abcdef1234567890abcdef")); | 440 | assert!(!commit_exists( |
| 441 | &repo_path, | ||
| 442 | "deadbeef1234567890abcdef1234567890abcdef" | ||
| 443 | )); | ||
| 336 | } | 444 | } |
| 337 | 445 | ||
| 338 | #[test] | 446 | #[test] |
| @@ -344,11 +452,11 @@ mod tests { | |||
| 344 | #[test] | 452 | #[test] |
| 345 | fn test_set_repository_head() { | 453 | fn test_set_repository_head() { |
| 346 | let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); | 454 | let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); |
| 347 | 455 | ||
| 348 | // Default HEAD might be refs/heads/master | 456 | // Default HEAD might be refs/heads/master |
| 349 | let result = set_repository_head(&repo_path, "refs/heads/main"); | 457 | let result = set_repository_head(&repo_path, "refs/heads/main"); |
| 350 | assert!(result.is_ok()); | 458 | assert!(result.is_ok()); |
| 351 | 459 | ||
| 352 | let head = get_repository_head(&repo_path); | 460 | let head = get_repository_head(&repo_path); |
| 353 | assert_eq!(head, Some("refs/heads/main".to_string())); | 461 | assert_eq!(head, Some("refs/heads/main".to_string())); |
| 354 | } | 462 | } |
| @@ -356,7 +464,7 @@ mod tests { | |||
| 356 | #[test] | 464 | #[test] |
| 357 | fn test_set_repository_head_invalid_ref() { | 465 | fn test_set_repository_head_invalid_ref() { |
| 358 | let (_temp_dir, repo_path) = create_test_repo(); | 466 | let (_temp_dir, repo_path) = create_test_repo(); |
| 359 | 467 | ||
| 360 | // Invalid ref format should fail | 468 | // Invalid ref format should fail |
| 361 | let result = set_repository_head(&repo_path, "main"); | 469 | let result = set_repository_head(&repo_path, "main"); |
| 362 | assert!(result.is_err()); | 470 | assert!(result.is_err()); |
| @@ -366,13 +474,13 @@ mod tests { | |||
| 366 | #[test] | 474 | #[test] |
| 367 | fn test_try_set_head_if_available_commit_missing() { | 475 | fn test_try_set_head_if_available_commit_missing() { |
| 368 | let (_temp_dir, repo_path) = create_test_repo(); | 476 | let (_temp_dir, repo_path) = create_test_repo(); |
| 369 | 477 | ||
| 370 | let result = try_set_head_if_available( | 478 | let result = try_set_head_if_available( |
| 371 | &repo_path, | 479 | &repo_path, |
| 372 | "refs/heads/main", | 480 | "refs/heads/main", |
| 373 | "deadbeef1234567890abcdef1234567890abcdef", | 481 | "deadbeef1234567890abcdef1234567890abcdef", |
| 374 | ); | 482 | ); |
| 375 | 483 | ||
| 376 | // Should return Ok(false) - commit not found | 484 | // Should return Ok(false) - commit not found |
| 377 | assert!(result.is_ok()); | 485 | assert!(result.is_ok()); |
| 378 | assert!(!result.unwrap()); | 486 | assert!(!result.unwrap()); |
| @@ -381,19 +489,15 @@ mod tests { | |||
| 381 | #[test] | 489 | #[test] |
| 382 | fn test_try_set_head_if_available_success() { | 490 | fn test_try_set_head_if_available_success() { |
| 383 | let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); | 491 | let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); |
| 384 | 492 | ||
| 385 | let result = try_set_head_if_available( | 493 | let result = try_set_head_if_available(&repo_path, "refs/heads/main", &commit_hash); |
| 386 | &repo_path, | 494 | |
| 387 | "refs/heads/main", | ||
| 388 | &commit_hash, | ||
| 389 | ); | ||
| 390 | |||
| 391 | // Should return Ok(true) - HEAD was set | 495 | // Should return Ok(true) - HEAD was set |
| 392 | assert!(result.is_ok()); | 496 | assert!(result.is_ok()); |
| 393 | assert!(result.unwrap()); | 497 | assert!(result.unwrap()); |
| 394 | 498 | ||
| 395 | // Verify HEAD was set | 499 | // Verify HEAD was set |
| 396 | let head = get_repository_head(&repo_path); | 500 | let head = get_repository_head(&repo_path); |
| 397 | assert_eq!(head, Some("refs/heads/main".to_string())); | 501 | assert_eq!(head, Some("refs/heads/main".to_string())); |
| 398 | } | 502 | } |
| 399 | } \ No newline at end of file | 503 | } |
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs index 7aa2b97..8e9926a 100644 --- a/src/nostr/builder.rs +++ b/src/nostr/builder.rs | |||
| @@ -14,8 +14,8 @@ use nostr_relay_builder::prelude::*; | |||
| 14 | use crate::config::{Config, DatabaseBackend}; | 14 | use crate::config::{Config, DatabaseBackend}; |
| 15 | use crate::git; | 15 | use crate::git; |
| 16 | use crate::nostr::events::{ | 16 | use crate::nostr::events::{ |
| 17 | validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, | 17 | validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR, |
| 18 | KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, | 18 | KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, |
| 19 | }; | 19 | }; |
| 20 | 20 | ||
| 21 | /// NIP-34 Write Policy with Full GRASP-01 Event Validation | 21 | /// NIP-34 Write Policy with Full GRASP-01 Event Validation |
| @@ -36,7 +36,11 @@ pub struct Nip34WritePolicy { | |||
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | impl Nip34WritePolicy { | 38 | impl Nip34WritePolicy { |
| 39 | pub fn new(domain: impl Into<String>, database: Arc<MemoryDatabase>, git_data_path: impl Into<PathBuf>) -> Self { | 39 | pub fn new( |
| 40 | domain: impl Into<String>, | ||
| 41 | database: Arc<MemoryDatabase>, | ||
| 42 | git_data_path: impl Into<PathBuf>, | ||
| 43 | ) -> Self { | ||
| 40 | Self { | 44 | Self { |
| 41 | domain: domain.into(), | 45 | domain: domain.into(), |
| 42 | database, | 46 | database, |
| @@ -48,7 +52,7 @@ impl Nip34WritePolicy { | |||
| 48 | /// Path format: <git_data_path>/<npub>/<identifier>.git | 52 | /// Path format: <git_data_path>/<npub>/<identifier>.git |
| 49 | fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { | 53 | fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { |
| 50 | let repo_path = self.git_data_path.join(&announcement.repo_path()); | 54 | let repo_path = self.git_data_path.join(&announcement.repo_path()); |
| 51 | 55 | ||
| 52 | // Check if repository already exists | 56 | // Check if repository already exists |
| 53 | if repo_path.exists() { | 57 | if repo_path.exists() { |
| 54 | tracing::debug!("Repository already exists at {}", repo_path.display()); | 58 | tracing::debug!("Repository already exists at {}", repo_path.display()); |
| @@ -56,13 +60,12 @@ impl Nip34WritePolicy { | |||
| 56 | } | 60 | } |
| 57 | 61 | ||
| 58 | // Create parent directory (npub directory) | 62 | // Create parent directory (npub directory) |
| 59 | let parent = repo_path.parent().ok_or_else(|| { | 63 | let parent = repo_path |
| 60 | format!("Invalid repository path: {}", repo_path.display()) | 64 | .parent() |
| 61 | })?; | 65 | .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?; |
| 62 | 66 | ||
| 63 | std::fs::create_dir_all(parent).map_err(|e| { | 67 | std::fs::create_dir_all(parent) |
| 64 | format!("Failed to create directory {}: {}", parent.display(), e) | 68 | .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?; |
| 65 | })?; | ||
| 66 | 69 | ||
| 67 | // Initialize bare repository using git command | 70 | // Initialize bare repository using git command |
| 68 | let output = std::process::Command::new("git") | 71 | let output = std::process::Command::new("git") |
| @@ -165,7 +168,11 @@ impl Nip34WritePolicy { | |||
| 165 | tracing::debug!( | 168 | tracing::debug!( |
| 166 | "Found authorized announcement for {}: owner={}, maintainer={}", | 169 | "Found authorized announcement for {}: owner={}, maintainer={}", |
| 167 | identifier, | 170 | identifier, |
| 168 | if is_owner { event.pubkey.to_hex() } else { "n/a".to_string() }, | 171 | if is_owner { |
| 172 | event.pubkey.to_hex() | ||
| 173 | } else { | ||
| 174 | "n/a".to_string() | ||
| 175 | }, | ||
| 169 | is_maintainer | 176 | is_maintainer |
| 170 | ); | 177 | ); |
| 171 | authorized.push(announcement); | 178 | authorized.push(announcement); |
| @@ -198,10 +205,7 @@ impl Nip34WritePolicy { | |||
| 198 | let head_ref = match &state.head { | 205 | let head_ref = match &state.head { |
| 199 | Some(h) => h, | 206 | Some(h) => h, |
| 200 | None => { | 207 | None => { |
| 201 | tracing::debug!( | 208 | tracing::debug!("State event for {} has no HEAD reference", state.identifier); |
| 202 | "State event for {} has no HEAD reference", | ||
| 203 | state.identifier | ||
| 204 | ); | ||
| 205 | return Ok(0); | 209 | return Ok(0); |
| 206 | } | 210 | } |
| 207 | }; | 211 | }; |
| @@ -232,11 +236,9 @@ impl Nip34WritePolicy { | |||
| 232 | }; | 236 | }; |
| 233 | 237 | ||
| 234 | // Find all announcements where state author is authorized | 238 | // Find all announcements where state author is authorized |
| 235 | let announcements = Self::find_authorized_announcements( | 239 | let announcements = |
| 236 | database, | 240 | Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey) |
| 237 | &state.identifier, | 241 | .await?; |
| 238 | &state.event.pubkey, | ||
| 239 | ).await?; | ||
| 240 | 242 | ||
| 241 | if announcements.is_empty() { | 243 | if announcements.is_empty() { |
| 242 | tracing::debug!( | 244 | tracing::debug!( |
| @@ -271,7 +273,7 @@ impl Nip34WritePolicy { | |||
| 271 | } | 273 | } |
| 272 | 274 | ||
| 273 | // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git | 275 | // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git |
| 274 | let repo_path = self.git_data_path.join(&announcement.repo_path()); | 276 | let repo_path = self.git_data_path.join(announcement.repo_path().clone()); |
| 275 | 277 | ||
| 276 | match git::try_set_head_if_available(&repo_path, head_ref, head_commit) { | 278 | match git::try_set_head_if_available(&repo_path, head_ref, head_commit) { |
| 277 | Ok(true) => { | 279 | Ok(true) => { |
| @@ -291,11 +293,7 @@ impl Nip34WritePolicy { | |||
| 291 | ); | 293 | ); |
| 292 | } | 294 | } |
| 293 | Err(e) => { | 295 | Err(e) => { |
| 294 | tracing::warn!( | 296 | tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e); |
| 295 | "Failed to set HEAD in {}: {}", | ||
| 296 | repo_path.display(), | ||
| 297 | e | ||
| 298 | ); | ||
| 299 | } | 297 | } |
| 300 | } | 298 | } |
| 301 | } | 299 | } |
| @@ -338,6 +336,191 @@ impl Nip34WritePolicy { | |||
| 338 | (addressable_refs, event_refs) | 336 | (addressable_refs, event_refs) |
| 339 | } | 337 | } |
| 340 | 338 | ||
| 339 | /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag | ||
| 340 | /// | ||
| 341 | /// When a PR event (kind 1618) or PR Update event (kind 1619) is received, | ||
| 342 | /// this checks if a corresponding refs/nostr/<event-id> ref exists in the | ||
| 343 | /// repository and validates that it points to the correct commit (from the | ||
| 344 | /// `c` tag). If the ref exists but points to a different commit, the ref is | ||
| 345 | /// deleted. | ||
| 346 | /// | ||
| 347 | /// PR and PR Update events can have multiple `a` tags to update multiple | ||
| 348 | /// repositories simultaneously. | ||
| 349 | /// | ||
| 350 | /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent | ||
| 351 | /// with their corresponding events. | ||
| 352 | /// | ||
| 353 | /// # Arguments | ||
| 354 | /// * `database` - Database for looking up repository announcements | ||
| 355 | /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619) | ||
| 356 | /// | ||
| 357 | /// # Returns | ||
| 358 | /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure | ||
| 359 | async fn validate_pr_nostr_ref( | ||
| 360 | &self, | ||
| 361 | database: &Arc<MemoryDatabase>, | ||
| 362 | event: &Event, | ||
| 363 | ) -> Result<Option<usize>, String> { | ||
| 364 | let event_id = event.id.to_hex(); | ||
| 365 | |||
| 366 | // Extract the `c` tag (commit hash) from the PR event | ||
| 367 | let expected_commit = event.tags.iter().find_map(|tag| { | ||
| 368 | let tag_vec = tag.clone().to_vec(); | ||
| 369 | if tag_vec.len() >= 2 && tag_vec[0] == "c" { | ||
| 370 | Some(tag_vec[1].clone()) | ||
| 371 | } else { | ||
| 372 | None | ||
| 373 | } | ||
| 374 | }); | ||
| 375 | |||
| 376 | let expected_commit = match expected_commit { | ||
| 377 | Some(c) => c, | ||
| 378 | None => { | ||
| 379 | tracing::debug!( | ||
| 380 | "PR event {} has no 'c' tag, skipping ref validation", | ||
| 381 | event_id | ||
| 382 | ); | ||
| 383 | return Ok(None); | ||
| 384 | } | ||
| 385 | }; | ||
| 386 | |||
| 387 | // Extract ALL `a` tags (repository references) from the PR event | ||
| 388 | // PR events can reference multiple repositories | ||
| 389 | // Format: 30617:<pubkey>:<identifier> | ||
| 390 | let repo_refs: Vec<String> = event | ||
| 391 | .tags | ||
| 392 | .iter() | ||
| 393 | .filter_map(|tag| { | ||
| 394 | let tag_vec = tag.clone().to_vec(); | ||
| 395 | if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") { | ||
| 396 | Some(tag_vec[1].clone()) | ||
| 397 | } else { | ||
| 398 | None | ||
| 399 | } | ||
| 400 | }) | ||
| 401 | .collect(); | ||
| 402 | |||
| 403 | if repo_refs.is_empty() { | ||
| 404 | tracing::debug!( | ||
| 405 | "PR event {} has no repo 'a' tags, skipping ref validation", | ||
| 406 | event_id | ||
| 407 | ); | ||
| 408 | return Ok(None); | ||
| 409 | } | ||
| 410 | |||
| 411 | let mut deleted_count = 0; | ||
| 412 | |||
| 413 | // Process each repository reference | ||
| 414 | for repo_ref in repo_refs { | ||
| 415 | // Parse the repo reference: 30617:<pubkey>:<identifier> | ||
| 416 | let parts: Vec<&str> = repo_ref.split(':').collect(); | ||
| 417 | if parts.len() < 3 { | ||
| 418 | tracing::debug!( | ||
| 419 | "PR event {} has invalid 'a' tag format: {}", | ||
| 420 | event_id, | ||
| 421 | repo_ref | ||
| 422 | ); | ||
| 423 | continue; | ||
| 424 | } | ||
| 425 | |||
| 426 | let repo_pubkey = match PublicKey::from_hex(parts[1]) { | ||
| 427 | Ok(pk) => pk, | ||
| 428 | Err(_) => { | ||
| 429 | tracing::debug!( | ||
| 430 | "PR event {} has invalid pubkey in 'a' tag: {}", | ||
| 431 | event_id, | ||
| 432 | parts[1] | ||
| 433 | ); | ||
| 434 | continue; | ||
| 435 | } | ||
| 436 | }; | ||
| 437 | let identifier = parts[2]; | ||
| 438 | |||
| 439 | // Look up repository announcement to get the npub for path | ||
| 440 | let filter = Filter::new() | ||
| 441 | .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT)) | ||
| 442 | .author(repo_pubkey) | ||
| 443 | .custom_tag( | ||
| 444 | SingleLetterTag::lowercase(Alphabet::D), | ||
| 445 | identifier.to_string(), | ||
| 446 | ); | ||
| 447 | |||
| 448 | let announcements: Vec<Event> = match database.query(filter).await { | ||
| 449 | Ok(events) => events.into_iter().collect(), | ||
| 450 | Err(e) => { | ||
| 451 | tracing::warn!( | ||
| 452 | "Failed to query for repository announcement for PR {}: {}", | ||
| 453 | event_id, | ||
| 454 | e | ||
| 455 | ); | ||
| 456 | continue; | ||
| 457 | } | ||
| 458 | }; | ||
| 459 | |||
| 460 | if announcements.is_empty() { | ||
| 461 | tracing::debug!( | ||
| 462 | "No repository announcement found for PR event {} (repo {}:{})", | ||
| 463 | event_id, | ||
| 464 | repo_pubkey.to_hex(), | ||
| 465 | identifier | ||
| 466 | ); | ||
| 467 | continue; | ||
| 468 | } | ||
| 469 | |||
| 470 | // Process each matching announcement (there could be multiple) | ||
| 471 | for announcement_event in announcements { | ||
| 472 | let announcement = match RepositoryAnnouncement::from_event(announcement_event) { | ||
| 473 | Ok(a) => a, | ||
| 474 | Err(e) => { | ||
| 475 | tracing::warn!( | ||
| 476 | "Failed to parse announcement for PR {} validation: {}", | ||
| 477 | event_id, | ||
| 478 | e | ||
| 479 | ); | ||
| 480 | continue; | ||
| 481 | } | ||
| 482 | }; | ||
| 483 | |||
| 484 | // Build repository path | ||
| 485 | let repo_path = self.git_data_path.join(&announcement.repo_path()); | ||
| 486 | |||
| 487 | // Validate the ref | ||
| 488 | match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) { | ||
| 489 | Ok(true) => { | ||
| 490 | tracing::info!( | ||
| 491 | "Deleted mismatched refs/nostr/{} in {} (expected commit {})", | ||
| 492 | event_id, | ||
| 493 | repo_path.display(), | ||
| 494 | expected_commit | ||
| 495 | ); | ||
| 496 | deleted_count += 1; | ||
| 497 | } | ||
| 498 | Ok(false) => { | ||
| 499 | tracing::debug!( | ||
| 500 | "refs/nostr/{} in {} is valid or doesn't exist", | ||
| 501 | event_id, | ||
| 502 | repo_path.display() | ||
| 503 | ); | ||
| 504 | } | ||
| 505 | Err(e) => { | ||
| 506 | tracing::warn!( | ||
| 507 | "Failed to validate refs/nostr/{} in {}: {}", | ||
| 508 | event_id, | ||
| 509 | repo_path.display(), | ||
| 510 | e | ||
| 511 | ); | ||
| 512 | } | ||
| 513 | } | ||
| 514 | } | ||
| 515 | } | ||
| 516 | |||
| 517 | if deleted_count > 0 { | ||
| 518 | Ok(Some(deleted_count)) | ||
| 519 | } else { | ||
| 520 | Ok(None) | ||
| 521 | } | ||
| 522 | } | ||
| 523 | |||
| 341 | /// Check if any addressable events (repositories) exist in database | 524 | /// Check if any addressable events (repositories) exist in database |
| 342 | /// Returns the first matching addressable reference found, or None if none match | 525 | /// Returns the first matching addressable reference found, or None if none match |
| 343 | async fn find_accepted_repository( | 526 | async fn find_accepted_repository( |
| @@ -377,16 +560,17 @@ impl Nip34WritePolicy { | |||
| 377 | use std::collections::HashMap; | 560 | use std::collections::HashMap; |
| 378 | let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new(); | 561 | let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new(); |
| 379 | for (addr, kind, pubkey, identifier) in parsed_refs { | 562 | for (addr, kind, pubkey, identifier) in parsed_refs { |
| 380 | by_kind.entry(kind).or_default().push((addr, pubkey, identifier)); | 563 | by_kind |
| 564 | .entry(kind) | ||
| 565 | .or_default() | ||
| 566 | .push((addr, pubkey, identifier)); | ||
| 381 | } | 567 | } |
| 382 | 568 | ||
| 383 | // Query each kind group | 569 | // Query each kind group |
| 384 | for (kind, refs) in by_kind { | 570 | for (kind, refs) in by_kind { |
| 385 | let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect(); | 571 | let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect(); |
| 386 | 572 | ||
| 387 | let filter = Filter::new() | 573 | let filter = Filter::new().kind(Kind::from(kind)).authors(authors); |
| 388 | .kind(Kind::from(kind)) | ||
| 389 | .authors(authors); | ||
| 390 | 574 | ||
| 391 | match database.query(filter).await { | 575 | match database.query(filter).await { |
| 392 | Ok(events) => { | 576 | Ok(events) => { |
| @@ -445,7 +629,7 @@ impl Nip34WritePolicy { | |||
| 445 | event: &Event, | 629 | event: &Event, |
| 446 | ) -> Result<bool, String> { | 630 | ) -> Result<bool, String> { |
| 447 | let kind_u16 = event.kind.as_u16(); | 631 | let kind_u16 = event.kind.as_u16(); |
| 448 | 632 | ||
| 449 | // Check if this is any kind of replaceable event | 633 | // Check if this is any kind of replaceable event |
| 450 | let is_regular_replaceable = kind_u16 >= 10000 && kind_u16 < 20000; | 634 | let is_regular_replaceable = kind_u16 >= 10000 && kind_u16 < 20000; |
| 451 | let is_parameterized_replaceable = kind_u16 >= 30000 && kind_u16 < 40000; | 635 | let is_parameterized_replaceable = kind_u16 >= 30000 && kind_u16 < 40000; |
| @@ -454,7 +638,9 @@ impl Nip34WritePolicy { | |||
| 454 | // Build the appropriate address format based on event type | 638 | // Build the appropriate address format based on event type |
| 455 | let address = if is_parameterized_replaceable { | 639 | let address = if is_parameterized_replaceable { |
| 456 | // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) | 640 | // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) |
| 457 | let identifier = event.tags.iter() | 641 | let identifier = event |
| 642 | .tags | ||
| 643 | .iter() | ||
| 458 | .find_map(|tag| { | 644 | .find_map(|tag| { |
| 459 | let tag_vec = tag.clone().to_vec(); | 645 | let tag_vec = tag.clone().to_vec(); |
| 460 | if tag_vec.len() >= 2 && tag_vec[0] == "d" { | 646 | if tag_vec.len() >= 2 && tag_vec[0] == "d" { |
| @@ -464,12 +650,17 @@ impl Nip34WritePolicy { | |||
| 464 | } | 650 | } |
| 465 | }) | 651 | }) |
| 466 | .unwrap_or_default(); // Empty string if no 'd' tag | 652 | .unwrap_or_default(); // Empty string if no 'd' tag |
| 467 | format!("{}:{}:{}", event.kind.as_u16(), event.pubkey.to_hex(), identifier) | 653 | format!( |
| 654 | "{}:{}:{}", | ||
| 655 | event.kind.as_u16(), | ||
| 656 | event.pubkey.to_hex(), | ||
| 657 | identifier | ||
| 658 | ) | ||
| 468 | } else { | 659 | } else { |
| 469 | // For regular replaceable: kind:pubkey format (1 colon) | 660 | // For regular replaceable: kind:pubkey format (1 colon) |
| 470 | format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) | 661 | format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) |
| 471 | }; | 662 | }; |
| 472 | 663 | ||
| 473 | // Check addressable reference tags: a, A, q (with address format) | 664 | // Check addressable reference tags: a, A, q (with address format) |
| 474 | let addressable_tags = [ | 665 | let addressable_tags = [ |
| 475 | SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference | 666 | SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference |
| @@ -479,7 +670,7 @@ impl Nip34WritePolicy { | |||
| 479 | 670 | ||
| 480 | for tag_type in &addressable_tags { | 671 | for tag_type in &addressable_tags { |
| 481 | let filter = Filter::new().custom_tag(tag_type.clone(), address.clone()); | 672 | let filter = Filter::new().custom_tag(tag_type.clone(), address.clone()); |
| 482 | 673 | ||
| 483 | match database.query(filter).await { | 674 | match database.query(filter).await { |
| 484 | Ok(events) => { | 675 | Ok(events) => { |
| 485 | if !events.is_empty() { | 676 | if !events.is_empty() { |
| @@ -492,7 +683,7 @@ impl Nip34WritePolicy { | |||
| 492 | } else { | 683 | } else { |
| 493 | // For regular events, check event ID reference tags: e, E, q (with hex ID) | 684 | // For regular events, check event ID reference tags: e, E, q (with hex ID) |
| 494 | let event_id_hex = event.id.to_hex(); | 685 | let event_id_hex = event.id.to_hex(); |
| 495 | 686 | ||
| 496 | let event_id_tags = [ | 687 | let event_id_tags = [ |
| 497 | SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference | 688 | SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference |
| 498 | SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference | 689 | SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference |
| @@ -501,7 +692,7 @@ impl Nip34WritePolicy { | |||
| 501 | 692 | ||
| 502 | for tag_type in &event_id_tags { | 693 | for tag_type in &event_id_tags { |
| 503 | let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone()); | 694 | let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone()); |
| 504 | 695 | ||
| 505 | match database.query(filter).await { | 696 | match database.query(filter).await { |
| 506 | Ok(events) => { | 697 | Ok(events) => { |
| 507 | if !events.is_empty() { | 698 | if !events.is_empty() { |
| @@ -545,7 +736,7 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 545 | // Note: We still accept the event even if repo creation fails | 736 | // Note: We still accept the event even if repo creation fails |
| 546 | // The git operation failure shouldn't prevent event acceptance | 737 | // The git operation failure shouldn't prevent event acceptance |
| 547 | } | 738 | } |
| 548 | 739 | ||
| 549 | tracing::debug!( | 740 | tracing::debug!( |
| 550 | "Accepted repository announcement: {}", | 741 | "Accepted repository announcement: {}", |
| 551 | event_id_str | 742 | event_id_str |
| @@ -563,11 +754,7 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 563 | } | 754 | } |
| 564 | } | 755 | } |
| 565 | Err(e) => { | 756 | Err(e) => { |
| 566 | tracing::warn!( | 757 | tracing::warn!("Rejected repository announcement {}: {}", event_id_str, e); |
| 567 | "Rejected repository announcement {}: {}", | ||
| 568 | event_id_str, | ||
| 569 | e | ||
| 570 | ); | ||
| 571 | PolicyResult::Reject(e.to_string()) | 758 | PolicyResult::Reject(e.to_string()) |
| 572 | } | 759 | } |
| 573 | }, | 760 | }, |
| @@ -577,7 +764,10 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 577 | match RepositoryState::from_event(event.clone()) { | 764 | match RepositoryState::from_event(event.clone()) { |
| 578 | Ok(state) => { | 765 | Ok(state) => { |
| 579 | // Try to set HEAD for all authorized repos if this is the latest state | 766 | // Try to set HEAD for all authorized repos if this is the latest state |
| 580 | match self.try_set_head_for_authorized_repos(&database, &state).await { | 767 | match self |
| 768 | .try_set_head_for_authorized_repos(&database, &state) | ||
| 769 | .await | ||
| 770 | { | ||
| 581 | Ok(count) if count > 0 => { | 771 | Ok(count) if count > 0 => { |
| 582 | tracing::info!( | 772 | tracing::info!( |
| 583 | "Set HEAD from state event {} for {} repo(s) with identifier {}", | 773 | "Set HEAD from state event {} for {} repo(s) with identifier {}", |
| @@ -600,11 +790,8 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 600 | ); | 790 | ); |
| 601 | } | 791 | } |
| 602 | } | 792 | } |
| 603 | 793 | ||
| 604 | tracing::debug!( | 794 | tracing::debug!("Accepted repository state: {}", event_id_str); |
| 605 | "Accepted repository state: {}", | ||
| 606 | event_id_str | ||
| 607 | ); | ||
| 608 | PolicyResult::Accept | 795 | PolicyResult::Accept |
| 609 | } | 796 | } |
| 610 | Err(e) => { | 797 | Err(e) => { |
| @@ -620,14 +807,104 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 620 | } | 807 | } |
| 621 | } | 808 | } |
| 622 | Err(e) => { | 809 | Err(e) => { |
| 810 | tracing::warn!("Rejected repository state {}: {}", event_id_str, e); | ||
| 811 | PolicyResult::Reject(e.to_string()) | ||
| 812 | } | ||
| 813 | }, | ||
| 814 | // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/<event-id> refs before acceptance | ||
| 815 | KIND_PR | KIND_PR_UPDATE => { | ||
| 816 | // Validate refs/nostr refs for this PR event | ||
| 817 | // This deletes any refs/nostr/<event-id> that points to wrong commit | ||
| 818 | if let Err(e) = self.validate_pr_nostr_ref(&database, event).await { | ||
| 623 | tracing::warn!( | 819 | tracing::warn!( |
| 624 | "Rejected repository state {}: {}", | 820 | "Failed to validate refs/nostr for PR event {}: {}", |
| 625 | event_id_str, | 821 | event_id_str, |
| 626 | e | 822 | e |
| 627 | ); | 823 | ); |
| 628 | PolicyResult::Reject(e.to_string()) | 824 | // Don't reject - just log the error and proceed with normal validation |
| 629 | } | 825 | } |
| 630 | }, | 826 | |
| 827 | // Continue with standard reference checking (same as default case) | ||
| 828 | let (addressable_refs, event_refs) = Self::extract_reference_tags(event); | ||
| 829 | |||
| 830 | // Check 1: Does this event reference an accepted repository? | ||
| 831 | match Self::find_accepted_repository(&database, &addressable_refs).await { | ||
| 832 | Ok(Some(addr_ref)) => { | ||
| 833 | tracing::debug!( | ||
| 834 | "Accepted PR event {}: references accepted repository {}", | ||
| 835 | event_id_str, | ||
| 836 | addr_ref | ||
| 837 | ); | ||
| 838 | return PolicyResult::Accept; | ||
| 839 | } | ||
| 840 | Ok(None) => { | ||
| 841 | // No matching repositories, continue to next check | ||
| 842 | } | ||
| 843 | Err(e) => { | ||
| 844 | tracing::warn!( | ||
| 845 | "Database query failed for PR {}, rejecting (fail-secure): {}", | ||
| 846 | event_id_str, | ||
| 847 | e | ||
| 848 | ); | ||
| 849 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 850 | } | ||
| 851 | } | ||
| 852 | |||
| 853 | // Check 2: Does this event reference an accepted event? | ||
| 854 | match Self::find_accepted_event(&database, &event_refs).await { | ||
| 855 | Ok(Some(event_ref)) => { | ||
| 856 | tracing::debug!( | ||
| 857 | "Accepted PR event {}: references accepted event {}", | ||
| 858 | event_id_str, | ||
| 859 | event_ref | ||
| 860 | ); | ||
| 861 | return PolicyResult::Accept; | ||
| 862 | } | ||
| 863 | Ok(None) => { | ||
| 864 | // No matching events, continue to next check | ||
| 865 | } | ||
| 866 | Err(e) => { | ||
| 867 | tracing::warn!( | ||
| 868 | "Database query failed for PR {}, rejecting (fail-secure): {}", | ||
| 869 | event_id_str, | ||
| 870 | e | ||
| 871 | ); | ||
| 872 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 873 | } | ||
| 874 | } | ||
| 875 | |||
| 876 | // Check 3: Is this event referenced by an accepted event? | ||
| 877 | match Self::is_referenced_by_accepted(&database, event).await { | ||
| 878 | Ok(true) => { | ||
| 879 | tracing::debug!( | ||
| 880 | "Accepted PR event {}: referenced by accepted event", | ||
| 881 | event_id_str | ||
| 882 | ); | ||
| 883 | return PolicyResult::Accept; | ||
| 884 | } | ||
| 885 | Ok(false) => { | ||
| 886 | // No forward references found, continue to rejection | ||
| 887 | } | ||
| 888 | Err(e) => { | ||
| 889 | tracing::warn!( | ||
| 890 | "Database query failed for PR {}, rejecting (fail-secure): {}", | ||
| 891 | event_id_str, | ||
| 892 | e | ||
| 893 | ); | ||
| 894 | return PolicyResult::Reject(format!("Database query failed: {}", e)); | ||
| 895 | } | ||
| 896 | } | ||
| 897 | |||
| 898 | // No valid references found - reject as orphan event | ||
| 899 | tracing::info!( | ||
| 900 | "Rejected orphan PR event {}: no references to accepted repos or events", | ||
| 901 | event_id_str | ||
| 902 | ); | ||
| 903 | PolicyResult::Reject( | ||
| 904 | "PR event must reference an accepted repository or accepted event" | ||
| 905 | .to_string(), | ||
| 906 | ) | ||
| 907 | } | ||
| 631 | // GRASP-01: Check if event references accepted repositories or events | 908 | // GRASP-01: Check if event references accepted repositories or events |
| 632 | _ => { | 909 | _ => { |
| 633 | // Extract all reference tags from event | 910 | // Extract all reference tags from event |
| @@ -709,7 +986,7 @@ impl WritePolicy for Nip34WritePolicy { | |||
| 709 | event_refs.len() | 986 | event_refs.len() |
| 710 | ); | 987 | ); |
| 711 | PolicyResult::Reject( | 988 | PolicyResult::Reject( |
| 712 | "Event must reference an accepted repository or accepted event".to_string() | 989 | "Event must reference an accepted repository or accepted event".to_string(), |
| 713 | ) | 990 | ) |
| 714 | } | 991 | } |
| 715 | } | 992 | } |
| @@ -786,4 +1063,4 @@ pub fn create_relay(config: &Config) -> Result<RelayWithDatabase> { | |||
| 786 | relay: LocalRelay::new(builder), | 1063 | relay: LocalRelay::new(builder), |
| 787 | database, | 1064 | database, |
| 788 | }) | 1065 | }) |
| 789 | } \ No newline at end of file | 1066 | } |
diff --git a/src/nostr/events.rs b/src/nostr/events.rs index 97688b1..6a62ccd 100644 --- a/src/nostr/events.rs +++ b/src/nostr/events.rs | |||
| @@ -15,6 +15,12 @@ pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617; | |||
| 15 | /// NIP-34 Repository State Announcement (kind 30618) | 15 | /// NIP-34 Repository State Announcement (kind 30618) |
| 16 | pub const KIND_REPOSITORY_STATE: u16 = 30618; | 16 | pub const KIND_REPOSITORY_STATE: u16 = 30618; |
| 17 | 17 | ||
| 18 | /// NIP-34 Pull Request (kind 1618) - has `c` tag for commit | ||
| 19 | pub const KIND_PR: u16 = 1618; | ||
| 20 | |||
| 21 | /// NIP-34 Pull Request Update (kind 1619) - has `c` tag for commit | ||
| 22 | pub const KIND_PR_UPDATE: u16 = 1619; | ||
| 23 | |||
| 18 | /// Repository announcement details extracted from NIP-34 event | 24 | /// Repository announcement details extracted from NIP-34 event |
| 19 | #[derive(Debug, Clone)] | 25 | #[derive(Debug, Clone)] |
| 20 | pub struct RepositoryAnnouncement { | 26 | pub struct RepositoryAnnouncement { |