diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-31 12:42:26 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-31 12:42:26 +0000 |
| commit | f5c8d167c3bf175dfe08ea3c8ca96055632364c3 (patch) | |
| tree | 093dbff7441dc04a4e99b0ec47e1cb9f47144bf9 | |
| parent | 91815d71549f98bcd425b53ee52fcb907b624f02 (diff) | |
purgatory: when state data recieved sync across repositoies
| -rw-r--r-- | grasp-audit/src/specs/grasp01/push_authorization.rs | 169 | ||||
| -rw-r--r-- | src/nostr/policy/state.rs | 107 | ||||
| -rw-r--r-- | tests/push_authorization.rs | 5 |
3 files changed, 255 insertions, 26 deletions
diff --git a/grasp-audit/src/specs/grasp01/push_authorization.rs b/grasp-audit/src/specs/grasp01/push_authorization.rs index ef494da..8bcf0f7 100644 --- a/grasp-audit/src/specs/grasp01/push_authorization.rs +++ b/grasp-audit/src/specs/grasp01/push_authorization.rs | |||
| @@ -766,32 +766,159 @@ impl PushAuthorizationTests { | |||
| 766 | /// | 766 | /// |
| 767 | /// GRASP-01: "respecting the recursive maintainer set" | 767 | /// GRASP-01: "respecting the recursive maintainer set" |
| 768 | /// | 768 | /// |
| 769 | /// This test verifies that when a maintainer publishes a state event, it updates | 769 | /// This test verifies that when a maintainer publishes a state event, the purgatory |
| 770 | /// the git repository state for all other maintainers' views. This ensures git | 770 | /// feature correctly copies git commits between repos so all authorized maintainers' |
| 771 | /// repositories always reflect the state according to nostr events (including state | 771 | /// repositories always reflect the state according to nostr events. |
| 772 | /// from recursive maintainers). | ||
| 773 | /// | 772 | /// |
| 774 | /// ## Implementation Note | 773 | /// ## Implementation |
| 775 | /// | 774 | /// |
| 776 | /// This test is a stub for the purgatory feature. It will be implemented as part | 775 | /// This test: |
| 777 | /// of GRASP-02 purgatory functionality. | 776 | /// 1. Uses RecursiveMaintainerStateDataPushed fixture which: |
| 777 | /// - Creates owner repo + state (ValidRepo, OwnerStateDataPushed) | ||
| 778 | /// - Creates maintainer announcement (separate repo for maintainer) | ||
| 779 | /// - Pushes recursive maintainer's git data to owner's repo | ||
| 780 | /// 2. Clones the maintainer's repository (not the owner's) | ||
| 781 | /// 3. Verifies that the maintainer's repo contains the recursive maintainer's state | ||
| 778 | /// | 782 | /// |
| 779 | /// ## Fixture Compatibility | 783 | /// This proves purgatory is working: git data was pushed to owner's repo, and purgatory |
| 780 | /// | 784 | /// synced it to the maintainer's repo based on the state event. |
| 781 | /// This test will use: | ||
| 782 | /// - `MaintainerStateDataPushed` - maintainer's state event with git data pushed | ||
| 783 | /// - Multiple maintainer clones to verify state propagation | ||
| 784 | #[allow(dead_code)] | ||
| 785 | pub async fn test_push_of_state_by_maintainer_updates_other_maintainer_repos( | 785 | pub async fn test_push_of_state_by_maintainer_updates_other_maintainer_repos( |
| 786 | _client: &AuditClient, | 786 | client: &AuditClient, |
| 787 | _relay_domain: &str, | 787 | relay_domain: &str, |
| 788 | ) -> TestResult { | 788 | ) -> TestResult { |
| 789 | TestResult::new( | 789 | let test_name = "test_push_of_state_by_maintainer_updates_other_maintainer_repos"; |
| 790 | "test_push_of_state_by_maintainer_updates_other_maintainer_repos", | 790 | let ctx = TestContext::new(client); |
| 791 | "GRASP-01:git-http:purgatory", | 791 | |
| 792 | "Maintainer state updates propagate to other maintainer repos", | 792 | // Get the RecursiveMaintainerStateDataPushed fixture which: |
| 793 | ) | 793 | // 1. Creates owner repo + owner state + git data |
| 794 | .fail("Not yet implemented - requires purgatory feature (GRASP-02)") | 794 | // 2. Creates maintainer announcement (separate repo) |
| 795 | // 3. Pushes recursive maintainer's git data to owner's repo | ||
| 796 | // 4. Purgatory should then sync to maintainer's repo | ||
| 797 | let recursive_state = match ctx | ||
| 798 | .get_fixture(FixtureKind::RecursiveMaintainerStateDataPushed) | ||
| 799 | .await | ||
| 800 | { | ||
| 801 | Ok(s) => s, | ||
| 802 | Err(e) => { | ||
| 803 | return TestResult::new( | ||
| 804 | test_name, | ||
| 805 | "GRASP-01:git-http:purgatory", | ||
| 806 | "Maintainer state updates propagate to other maintainer repos", | ||
| 807 | ) | ||
| 808 | .fail(format!( | ||
| 809 | "Failed to get RecursiveMaintainerStateDataPushed fixture: {}", | ||
| 810 | e | ||
| 811 | )) | ||
| 812 | } | ||
| 813 | }; | ||
| 814 | |||
| 815 | // Small delay to ensure state processing completes | ||
| 816 | tokio::time::sleep(Duration::from_millis(200)).await; | ||
| 817 | |||
| 818 | // Extract repo_id from the recursive maintainer's state event | ||
| 819 | let repo_id = match recursive_state | ||
| 820 | .tags | ||
| 821 | .iter() | ||
| 822 | .find(|t| t.kind() == TagKind::d()) | ||
| 823 | .and_then(|t| t.content()) | ||
| 824 | { | ||
| 825 | Some(id) => id.to_string(), | ||
| 826 | None => { | ||
| 827 | return TestResult::new( | ||
| 828 | test_name, | ||
| 829 | "GRASP-01:git-http:purgatory", | ||
| 830 | "Maintainer state updates propagate to other maintainer repos", | ||
| 831 | ) | ||
| 832 | .fail("No repo identifier in recursive maintainer state event") | ||
| 833 | } | ||
| 834 | }; | ||
| 835 | |||
| 836 | // Get the maintainer's npub | ||
| 837 | let maintainer_npub = match client.maintainer_keys().public_key().to_bech32() { | ||
| 838 | Ok(npub) => npub, | ||
| 839 | Err(e) => { | ||
| 840 | return TestResult::new( | ||
| 841 | test_name, | ||
| 842 | "GRASP-01:git-http:purgatory", | ||
| 843 | "Maintainer state updates propagate to other maintainer repos", | ||
| 844 | ) | ||
| 845 | .fail(format!( | ||
| 846 | "Failed to convert maintainer pubkey to npub: {}", | ||
| 847 | e | ||
| 848 | )) | ||
| 849 | } | ||
| 850 | }; | ||
| 851 | |||
| 852 | // Use the known recursive maintainer deterministic commit hash from the fixture | ||
| 853 | let expected_commit = crate::RECURSIVE_MAINTAINER_DETERMINISTIC_COMMIT_HASH; | ||
| 854 | |||
| 855 | // Clone the maintainer's repository (NOT the owner's) | ||
| 856 | // This is the key test: git data was pushed to owner's repo, does maintainer's repo have it? | ||
| 857 | let clone_path = match clone_repo(relay_domain, &maintainer_npub, &repo_id) { | ||
| 858 | Ok(path) => path, | ||
| 859 | Err(e) => { | ||
| 860 | return TestResult::new( | ||
| 861 | test_name, | ||
| 862 | "GRASP-01:git-http:purgatory", | ||
| 863 | "Maintainer state updates propagate to other maintainer repos", | ||
| 864 | ) | ||
| 865 | .fail(format!("Failed to clone maintainer's repo: {}", e)) | ||
| 866 | } | ||
| 867 | }; | ||
| 868 | |||
| 869 | let cleanup = || { | ||
| 870 | let _ = fs::remove_dir_all(&clone_path); | ||
| 871 | }; | ||
| 872 | |||
| 873 | // Verify that the maintainer's repo contains the recursive maintainer's commit | ||
| 874 | // This proves purgatory copied it from owner's repo | ||
| 875 | let commit_exists_output = Command::new("git") | ||
| 876 | .args(["cat-file", "-t", expected_commit]) | ||
| 877 | .current_dir(&clone_path) | ||
| 878 | .output(); | ||
| 879 | |||
| 880 | let commit_exists = match commit_exists_output { | ||
| 881 | Ok(output) => { | ||
| 882 | if output.status.success() { | ||
| 883 | let obj_type = String::from_utf8_lossy(&output.stdout); | ||
| 884 | obj_type.trim() == "commit" | ||
| 885 | } else { | ||
| 886 | false | ||
| 887 | } | ||
| 888 | } | ||
| 889 | Err(e) => { | ||
| 890 | cleanup(); | ||
| 891 | return TestResult::new( | ||
| 892 | test_name, | ||
| 893 | "GRASP-01:git-http:purgatory", | ||
| 894 | "Maintainer state updates propagate to other maintainer repos", | ||
| 895 | ) | ||
| 896 | .fail(format!("Failed to check if commit exists: {}", e)); | ||
| 897 | } | ||
| 898 | }; | ||
| 899 | |||
| 900 | cleanup(); | ||
| 901 | |||
| 902 | if commit_exists { | ||
| 903 | TestResult::new( | ||
| 904 | test_name, | ||
| 905 | "GRASP-01:git-http:purgatory", | ||
| 906 | "Maintainer state updates propagate to other maintainer repos", | ||
| 907 | ) | ||
| 908 | .pass() | ||
| 909 | } else { | ||
| 910 | TestResult::new( | ||
| 911 | test_name, | ||
| 912 | "GRASP-01:git-http:purgatory", | ||
| 913 | "Maintainer state updates propagate to other maintainer repos", | ||
| 914 | ) | ||
| 915 | .fail(format!( | ||
| 916 | "Maintainer's repo does not contain recursive maintainer's commit {}. \ | ||
| 917 | Git data was pushed to owner's repo, but purgatory did not copy it to \ | ||
| 918 | maintainer's repo. This indicates the purgatory feature is not working correctly.", | ||
| 919 | expected_commit | ||
| 920 | )) | ||
| 921 | } | ||
| 795 | } | 922 | } |
| 796 | 923 | ||
| 797 | /// Test that non-maintainer state event is ignored | 924 | /// Test that non-maintainer state event is ignored |
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs index 1203890..48435ea 100644 --- a/src/nostr/policy/state.rs +++ b/src/nostr/policy/state.rs | |||
| @@ -125,7 +125,27 @@ impl StatePolicy { | |||
| 125 | { | 125 | { |
| 126 | let repo_path = | 126 | let repo_path = |
| 127 | self.ctx.git_data_path.join(annoucement.repo_path().clone()); | 127 | self.ctx.git_data_path.join(annoucement.repo_path().clone()); |
| 128 | // TODO - if repo_path != repo_with_git_data, pass as a datasource for missing data? | 128 | |
| 129 | if !repo_path.exists() { | ||
| 130 | // eg if annoucement doesnt list repo (but stored as its in maintainer set) | ||
| 131 | continue; | ||
| 132 | } | ||
| 133 | // If repo_path != repo_with_git_data, copy missing oids first | ||
| 134 | if repo_path != repo_with_git_data { | ||
| 135 | if let Err(e) = self.copy_missing_oids( | ||
| 136 | &repo_with_git_data, | ||
| 137 | &repo_path, | ||
| 138 | &state, | ||
| 139 | ) { | ||
| 140 | tracing::warn!( | ||
| 141 | "Failed to copy oids from {} to {}: {}", | ||
| 142 | repo_with_git_data.display(), | ||
| 143 | repo_path.display(), | ||
| 144 | e | ||
| 145 | ); | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 129 | let result = self.align_repository_with_state(&repo_path, &state); | 149 | let result = self.align_repository_with_state(&repo_path, &state); |
| 130 | repo_count += 1; | 150 | repo_count += 1; |
| 131 | tracing::info!( | 151 | tracing::info!( |
| @@ -335,6 +355,91 @@ impl StatePolicy { | |||
| 335 | 355 | ||
| 336 | result | 356 | result |
| 337 | } | 357 | } |
| 358 | |||
| 359 | /// Copy missing OIDs from a source repository to a target repository | ||
| 360 | /// | ||
| 361 | /// Identifies commits referenced in the state that are missing from the target | ||
| 362 | /// repository and copies them from the source repository using git fetch. | ||
| 363 | /// | ||
| 364 | /// # Arguments | ||
| 365 | /// * `source_repo` - Path to repository containing the commits | ||
| 366 | /// * `target_repo` - Path to repository to receive the commits | ||
| 367 | /// * `state` - Repository state containing commit references | ||
| 368 | /// | ||
| 369 | /// # Returns | ||
| 370 | /// Ok(()) on success, Err with error message on failure | ||
| 371 | fn copy_missing_oids( | ||
| 372 | &self, | ||
| 373 | source_repo: &Path, | ||
| 374 | target_repo: &Path, | ||
| 375 | state: &RepositoryState, | ||
| 376 | ) -> Result<(), String> { | ||
| 377 | use std::process::Command; | ||
| 378 | |||
| 379 | // Collect all commits referenced in the state | ||
| 380 | let mut commits_to_check = Vec::new(); | ||
| 381 | |||
| 382 | for branch in &state.branches { | ||
| 383 | if !branch.commit.starts_with("ref: ") { | ||
| 384 | commits_to_check.push(&branch.commit); | ||
| 385 | } | ||
| 386 | } | ||
| 387 | |||
| 388 | for tag in &state.tags { | ||
| 389 | if !tag.commit.starts_with("ref: ") { | ||
| 390 | commits_to_check.push(&tag.commit); | ||
| 391 | } | ||
| 392 | } | ||
| 393 | |||
| 394 | // Identify missing commits | ||
| 395 | let mut missing_commits = Vec::new(); | ||
| 396 | for commit in commits_to_check { | ||
| 397 | if !git::oid_exists(target_repo, commit) { | ||
| 398 | missing_commits.push(commit); | ||
| 399 | } | ||
| 400 | } | ||
| 401 | |||
| 402 | if missing_commits.is_empty() { | ||
| 403 | tracing::debug!( | ||
| 404 | "No missing commits to copy from {} to {}", | ||
| 405 | source_repo.display(), | ||
| 406 | target_repo.display() | ||
| 407 | ); | ||
| 408 | return Ok(()); | ||
| 409 | } | ||
| 410 | |||
| 411 | tracing::info!( | ||
| 412 | "Copying {} missing commits from {} to {}", | ||
| 413 | missing_commits.len(), | ||
| 414 | source_repo.display(), | ||
| 415 | target_repo.display() | ||
| 416 | ); | ||
| 417 | |||
| 418 | // Fetch each missing commit from source to target | ||
| 419 | for commit in &missing_commits { | ||
| 420 | let output = Command::new("git") | ||
| 421 | .args([ | ||
| 422 | "fetch", | ||
| 423 | source_repo.to_str().ok_or("Invalid source path")?, | ||
| 424 | commit, | ||
| 425 | ]) | ||
| 426 | .current_dir(target_repo) | ||
| 427 | .output() | ||
| 428 | .map_err(|e| format!("Failed to execute git fetch: {}", e))?; | ||
| 429 | |||
| 430 | if !output.status.success() { | ||
| 431 | let stderr = String::from_utf8_lossy(&output.stderr); | ||
| 432 | return Err(format!( | ||
| 433 | "git fetch failed for commit {}: {}", | ||
| 434 | commit, stderr | ||
| 435 | )); | ||
| 436 | } | ||
| 437 | |||
| 438 | tracing::debug!("Copied commit {} to {}", commit, target_repo.display()); | ||
| 439 | } | ||
| 440 | |||
| 441 | Ok(()) | ||
| 442 | } | ||
| 338 | } | 443 | } |
| 339 | 444 | ||
| 340 | fn find_repo_with_git_data( | 445 | fn find_repo_with_git_data( |
diff --git a/tests/push_authorization.rs b/tests/push_authorization.rs index 85b9a5d..7047010 100644 --- a/tests/push_authorization.rs +++ b/tests/push_authorization.rs | |||
| @@ -72,7 +72,4 @@ isolated_push_test!( | |||
| 72 | ); | 72 | ); |
| 73 | isolated_push_test!(test_head_set_after_state_event_with_existing_commit); | 73 | isolated_push_test!(test_head_set_after_state_event_with_existing_commit); |
| 74 | isolated_push_test!(test_head_set_after_git_push_with_required_oids); | 74 | isolated_push_test!(test_head_set_after_git_push_with_required_oids); |
| 75 | 75 | isolated_push_test!(test_push_of_state_by_maintainer_updates_other_maintainer_repos); | |
| 76 | // Note: test_push_of_state_by_maintainer_updates_other_maintainer_repos is not included | ||
| 77 | // as it's a stub for the purgatory feature. It can be run manually once implemented: | ||
| 78 | // isolated_push_test!(test_push_of_state_by_maintainer_updates_other_maintainer_repos); | ||