diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-25 10:53:53 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2026-02-25 10:53:53 +0000 |
| commit | a6bbcc8b16235903fa2fee75a90618ed57bc89a7 (patch) | |
| tree | 9553a52cd1446c5f8327d56a28397efcfc39c7ba /grasp-audit | |
| parent | cd01c7379f23d9189beef840ddc523a3c90a9a10 (diff) | |
fix git_refs_match_state in read-only mode to fetch state events from relay
In read-only mode, fetch all served kind:30618 state events for the
repo by #d tag. The relay already validates authorization (including
recursive maintainer chains), so any served state event is authoritative.
Derive expected refs by taking the latest-timestamp state event per ref
across all served events, then compare against git info/refs output.
Diffstat (limited to 'grasp-audit')
| -rw-r--r-- | grasp-audit/src/probe.rs | 131 |
1 files changed, 123 insertions, 8 deletions
diff --git a/grasp-audit/src/probe.rs b/grasp-audit/src/probe.rs index c626b94..d6c1482 100644 --- a/grasp-audit/src/probe.rs +++ b/grasp-audit/src/probe.rs | |||
| @@ -799,8 +799,10 @@ pub async fn run_probe( | |||
| 799 | .await; | 799 | .await; |
| 800 | let step6_ms = step6_start.elapsed().as_millis() as u64; | 800 | let step6_ms = step6_start.elapsed().as_millis() as u64; |
| 801 | 801 | ||
| 802 | match refs_result { | 802 | // Capture body for git_refs_match_state if fetch succeeds |
| 803 | let refs_body_fallback: Option<String> = match refs_result { | ||
| 803 | Ok(Ok(resp)) if resp.status().is_success() => { | 804 | Ok(Ok(resp)) if resp.status().is_success() => { |
| 805 | let body = resp.text().await.unwrap_or_default(); | ||
| 804 | checks.push(ProbeCheck { | 806 | checks.push(ProbeCheck { |
| 805 | name: "git_fetch_refs", | 807 | name: "git_fetch_refs", |
| 806 | passed: true, | 808 | passed: true, |
| @@ -809,6 +811,7 @@ pub async fn run_probe( | |||
| 809 | detail: detail_id, | 811 | detail: detail_id, |
| 810 | error: None, | 812 | error: None, |
| 811 | }); | 813 | }); |
| 814 | Some(body) | ||
| 812 | } | 815 | } |
| 813 | Ok(Ok(resp)) => { | 816 | Ok(Ok(resp)) => { |
| 814 | checks.push(ProbeCheck { | 817 | checks.push(ProbeCheck { |
| @@ -819,6 +822,7 @@ pub async fn run_probe( | |||
| 819 | detail: detail_id, | 822 | detail: detail_id, |
| 820 | error: Some(format!("HTTP {}", resp.status())), | 823 | error: Some(format!("HTTP {}", resp.status())), |
| 821 | }); | 824 | }); |
| 825 | None | ||
| 822 | } | 826 | } |
| 823 | Ok(Err(e)) => { | 827 | Ok(Err(e)) => { |
| 824 | checks.push(ProbeCheck { | 828 | checks.push(ProbeCheck { |
| @@ -829,6 +833,7 @@ pub async fn run_probe( | |||
| 829 | detail: detail_id, | 833 | detail: detail_id, |
| 830 | error: Some(e.to_string()), | 834 | error: Some(e.to_string()), |
| 831 | }); | 835 | }); |
| 836 | None | ||
| 832 | } | 837 | } |
| 833 | Err(_) => { | 838 | Err(_) => { |
| 834 | checks.push(ProbeCheck { | 839 | checks.push(ProbeCheck { |
| @@ -839,14 +844,124 @@ pub async fn run_probe( | |||
| 839 | detail: detail_id, | 844 | detail: detail_id, |
| 840 | error: Some("timeout".to_string()), | 845 | error: Some("timeout".to_string()), |
| 841 | }); | 846 | }); |
| 847 | None | ||
| 842 | } | 848 | } |
| 843 | } | 849 | }; |
| 844 | 850 | ||
| 845 | // git_refs_match_state is skipped in fallback — no state event to compare | 851 | // git_refs_match_state: fetch all served kind 30618 state events for this |
| 846 | checks.push(skipped( | 852 | // repo (by #d tag), derive expected refs (latest timestamp wins per ref |
| 847 | "git_refs_match_state", | 853 | // across all authorized state events — relay already validated auth, |
| 848 | "no state event (fallback path)", | 854 | // including recursive maintainer chains), then compare against git refs. |
| 849 | )); | 855 | match refs_body_fallback { |
| 856 | None => { | ||
| 857 | checks.push(skipped( | ||
| 858 | "git_refs_match_state", | ||
| 859 | "git_fetch_refs failed", | ||
| 860 | )); | ||
| 861 | } | ||
| 862 | Some(body) => { | ||
| 863 | let fetched_refs = parse_refs(&body); | ||
| 864 | |||
| 865 | // Fetch all state events for this repo_id from the relay. | ||
| 866 | // The relay only serves authorized state events (owner + full | ||
| 867 | // recursive maintainer chain already resolved by the relay). | ||
| 868 | let state_filter = Filter::new() | ||
| 869 | .kind(Kind::RepoState) | ||
| 870 | .custom_tag( | ||
| 871 | nostr_sdk::prelude::SingleLetterTag::lowercase( | ||
| 872 | nostr_sdk::prelude::Alphabet::D, | ||
| 873 | ), | ||
| 874 | ann_id.clone(), | ||
| 875 | ); | ||
| 876 | let state_events = client | ||
| 877 | .client() | ||
| 878 | .fetch_events(state_filter, Duration::from_secs(5)) | ||
| 879 | .await | ||
| 880 | .unwrap_or_default(); | ||
| 881 | |||
| 882 | if state_events.is_empty() { | ||
| 883 | checks.push(ProbeCheck { | ||
| 884 | name: "git_refs_match_state", | ||
| 885 | passed: false, | ||
| 886 | skipped: false, | ||
| 887 | duration_ms: 0, | ||
| 888 | detail: None, | ||
| 889 | error: Some( | ||
| 890 | "no kind:30618 state events found for this repo".to_string(), | ||
| 891 | ), | ||
| 892 | }); | ||
| 893 | } else { | ||
| 894 | // Build expected refs: for each ref name, the state event with | ||
| 895 | // the highest created_at timestamp wins (mirrors relay behaviour). | ||
| 896 | // This correctly handles recursive maintainership — any authorized | ||
| 897 | // party's state event may be the most recent for a given ref. | ||
| 898 | let mut expected: std::collections::HashMap<String, String> = | ||
| 899 | std::collections::HashMap::new(); | ||
| 900 | let mut latest_ts: std::collections::HashMap<String, u64> = | ||
| 901 | std::collections::HashMap::new(); | ||
| 902 | |||
| 903 | for state_ev in state_events.iter() { | ||
| 904 | let ts = state_ev.created_at.as_secs(); | ||
| 905 | for tag in state_ev.tags.iter() { | ||
| 906 | let kind_str = match tag.kind() { | ||
| 907 | TagKind::Custom(ref s) => s.clone(), | ||
| 908 | _ => continue, | ||
| 909 | }; | ||
| 910 | if !kind_str.starts_with("refs/heads/") | ||
| 911 | && !kind_str.starts_with("refs/tags/") | ||
| 912 | { | ||
| 913 | continue; | ||
| 914 | } | ||
| 915 | let hash = match tag.content() { | ||
| 916 | Some(h) => h.to_string(), | ||
| 917 | None => continue, | ||
| 918 | }; | ||
| 919 | let prev_ts = latest_ts.get(kind_str.as_ref()).copied().unwrap_or(0); | ||
| 920 | if ts >= prev_ts { | ||
| 921 | expected.insert(kind_str.to_string(), hash); | ||
| 922 | latest_ts.insert(kind_str.to_string(), ts); | ||
| 923 | } | ||
| 924 | } | ||
| 925 | } | ||
| 926 | |||
| 927 | let mut mismatches: Vec<String> = Vec::new(); | ||
| 928 | for (refname, expected_hash) in &expected { | ||
| 929 | let found = fetched_refs.iter().find(|(r, _)| r == refname); | ||
| 930 | match found { | ||
| 931 | Some((_, actual_hash)) if actual_hash == expected_hash => {} | ||
| 932 | Some((_, actual_hash)) => { | ||
| 933 | mismatches.push(format!( | ||
| 934 | "{}: expected {} got {}", | ||
| 935 | refname, | ||
| 936 | &expected_hash[..8.min(expected_hash.len())], | ||
| 937 | &actual_hash[..8.min(actual_hash.len())] | ||
| 938 | )); | ||
| 939 | } | ||
| 940 | None => { | ||
| 941 | mismatches.push(format!( | ||
| 942 | "{}: expected {} not found in refs", | ||
| 943 | refname, | ||
| 944 | &expected_hash[..8.min(expected_hash.len())] | ||
| 945 | )); | ||
| 946 | } | ||
| 947 | } | ||
| 948 | } | ||
| 949 | |||
| 950 | checks.push(ProbeCheck { | ||
| 951 | name: "git_refs_match_state", | ||
| 952 | passed: mismatches.is_empty(), | ||
| 953 | skipped: false, | ||
| 954 | duration_ms: 0, | ||
| 955 | detail: None, | ||
| 956 | error: if mismatches.is_empty() { | ||
| 957 | None | ||
| 958 | } else { | ||
| 959 | Some(mismatches.join("; ")) | ||
| 960 | }, | ||
| 961 | }); | ||
| 962 | } | ||
| 963 | } | ||
| 964 | } | ||
| 850 | } | 965 | } |
| 851 | None => { | 966 | None => { |
| 852 | // Not read-only (already handled above) but no repo found | 967 | // Not read-only (already handled above) but no repo found |
| @@ -860,7 +975,7 @@ pub async fn run_probe( | |||
| 860 | }); | 975 | }); |
| 861 | checks.push(skipped( | 976 | checks.push(skipped( |
| 862 | "git_refs_match_state", | 977 | "git_refs_match_state", |
| 863 | "no state event (fallback path)", | 978 | "no announcement found", |
| 864 | )); | 979 | )); |
| 865 | } | 980 | } |
| 866 | } | 981 | } |