upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-02-25 10:53:53 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-02-25 10:53:53 +0000
commita6bbcc8b16235903fa2fee75a90618ed57bc89a7 (patch)
tree9553a52cd1446c5f8327d56a28397efcfc39c7ba
parentcd01c7379f23d9189beef840ddc523a3c90a9a10 (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.
-rw-r--r--grasp-audit/src/probe.rs131
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 }