upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2026-04-22 13:00:17 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2026-04-22 13:00:17 +0000
commite98acf8c5da5033b20aee8510485a8b4a4f4a56e (patch)
treee513e9edb9feca3d9c079026002fbe9cd3d0cec9 /src
parent205ca05897cbc727d9b75e7ab68375b5c93ead39 (diff)
fix: prevent fatal clone/fetch errors when PR git data unavailable
Only advertise `refs/heads/pr/*` branches once their tip OIDs are confirmed present locally; prevents `fatal: bad object` / `remote did not send all necessary objects` errors during clone/fetch when a PR tip lives on a different git server than the one that won the bulk prefetch race. After the bulk prefetch, collect remaining missing PR tip OIDs and do one batch fetch per repo git server using only the OIDs that server has advertised; break early once all are satisfied and skip servers that carry none. Avoids batch-poisoning (a server rejects the whole request if any single OID is absent) and redundant connections. Restrict mop-up fetches and run_fetch to the repo's declared git servers; do not fetch from clone-tag URLs in PR events - they are submitter-supplied and could let a malicious or slow server stall every clone/fetch operation. Also apply rustfmt and fix clippy warnings.
Diffstat (limited to 'src')
-rw-r--r--src/bin/git_remote_nostr/fetch.rs65
-rw-r--r--src/bin/git_remote_nostr/list.rs82
2 files changed, 80 insertions, 67 deletions
diff --git a/src/bin/git_remote_nostr/fetch.rs b/src/bin/git_remote_nostr/fetch.rs
index dd80f0f..7847777 100644
--- a/src/bin/git_remote_nostr/fetch.rs
+++ b/src/bin/git_remote_nostr/fetch.rs
@@ -1,17 +1,11 @@
1use core::str; 1use core::str;
2use std::{ 2use std::{collections::HashMap, io::Stdin};
3 collections::{HashMap, HashSet},
4 io::Stdin,
5};
6 3
7use anyhow::{Context, Result, bail}; 4use anyhow::{Context, Result, bail};
8use ngit::{ 5use ngit::{
9 fetch::fetch_from_git_server, 6 fetch::fetch_from_git_server,
10 git::{Repo, RepoActions}, 7 git::{Repo, RepoActions},
11 git_events::{ 8 git_events::{KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE},
12 KIND_PULL_REQUEST, KIND_PULL_REQUEST_UPDATE,
13 identify_clone_urls_for_oids_from_pr_pr_update_events,
14 },
15 login::get_curent_user, 9 login::get_curent_user,
16 repo_ref::{RepoRef, is_grasp_server_in_list}, 10 repo_ref::{RepoRef, is_grasp_server_in_list},
17 utils::{ 11 utils::{
@@ -37,56 +31,29 @@ pub async fn run_fetch(
37 .map(|(_, oid)| oid.clone()) 31 .map(|(_, oid)| oid.clone())
38 .collect::<Vec<String>>(); 32 .collect::<Vec<String>>();
39 33
40 let pr_oid_clone_url_map = identify_clone_urls_for_oids_from_pr_pr_update_events(
41 fetch_batch.values().collect::<Vec<&String>>(),
42 git_repo,
43 repo_ref,
44 )
45 .await?;
46
47 let oids_to_fetch_from_git_servers = [
48 oids_from_state.clone(),
49 pr_oid_clone_url_map
50 .keys()
51 .cloned()
52 .collect::<Vec<String>>(),
53 ]
54 .concat();
55
56 let git_servers = {
57 let mut seen: HashSet<String> = HashSet::new();
58 let mut out: Vec<String> = vec![];
59 for server in &repo_ref.git_server {
60 if seen.insert(server.clone()) {
61 out.push(server.clone());
62 }
63 }
64 for url in pr_oid_clone_url_map.values().flatten() {
65 if seen.insert(url.clone()) {
66 out.push(url.clone());
67 }
68 }
69 out
70 };
71
72 let mut errors = vec![]; 34 let mut errors = vec![];
73 let term = console::Term::stderr(); 35 let term = console::Term::stderr();
74 36
75 for git_server_url in &git_servers { 37 // Only repo git servers are tried here. PR tip OIDs are guaranteed local by the
76 let oids_to_fetch_from_server = oids_to_fetch_from_git_servers 38 // list phase (see get_open_and_draft_proposals_state), so run_fetch only needs
77 .clone() 39 // to resolve state OIDs that may have been missed by the bulk prefetch.
78 .into_iter() 40 // We intentionally do not fall back to git-server URLs in PR event `clone`
41 // tags; see the note in get_open_and_draft_proposals_state for the
42 // rationale.
43 for git_server_url in &repo_ref.git_server {
44 let missing = oids_from_state
45 .iter()
79 .filter(|oid| !git_repo.does_commit_exist(oid).unwrap_or(false)) 46 .filter(|oid| !git_repo.does_commit_exist(oid).unwrap_or(false))
47 .cloned()
80 .collect::<Vec<String>>(); 48 .collect::<Vec<String>>();
81 49
82 if oids_to_fetch_from_server.is_empty() { 50 if missing.is_empty() {
83 continue; 51 break;
84 } 52 }
85 53
86 let term = console::Term::stderr();
87 if let Err(error) = fetch_from_git_server( 54 if let Err(error) = fetch_from_git_server(
88 git_repo, 55 git_repo,
89 &oids_from_state, 56 &missing,
90 git_server_url, 57 git_server_url,
91 &repo_ref.to_nostr_git_url(&None), 58 &repo_ref.to_nostr_git_url(&None),
92 &term, 59 &term,
@@ -98,7 +65,7 @@ pub async fn run_fetch(
98 65
99 if oids_from_state 66 if oids_from_state
100 .iter() 67 .iter()
101 .any(|oid| !git_repo.does_commit_exist(oid).unwrap()) 68 .any(|oid| !git_repo.does_commit_exist(oid).unwrap_or(false))
102 && !errors.is_empty() 69 && !errors.is_empty()
103 { 70 {
104 bail!( 71 bail!(
diff --git a/src/bin/git_remote_nostr/list.rs b/src/bin/git_remote_nostr/list.rs
index a32ed67..23b3b98 100644
--- a/src/bin/git_remote_nostr/list.rs
+++ b/src/bin/git_remote_nostr/list.rs
@@ -32,13 +32,14 @@ pub async fn run_list(
32 if is_verbose() { 32 if is_verbose() {
33 term.write_line("git servers: listing refs...")?; 33 term.write_line("git servers: listing refs...")?;
34 } 34 }
35 let nostr_git_url = repo_ref.to_nostr_git_url(&None);
35 // nostr_state is passed to list_from_remotes only for the sync-status 36 // nostr_state is passed to list_from_remotes only for the sync-status
36 // display; the actual ref state we advertise is determined below. 37 // display; the actual ref state we advertise is determined below.
37 let remote_states = list_from_remotes( 38 let remote_states = list_from_remotes(
38 &term, 39 &term,
39 git_repo, 40 git_repo,
40 &repo_ref.git_server, 41 &repo_ref.git_server,
41 &repo_ref.to_nostr_git_url(&None), 42 &nostr_git_url,
42 nostr_state.as_ref(), 43 nostr_state.as_ref(),
43 ) 44 )
44 .await; 45 .await;
@@ -134,6 +135,7 @@ pub async fn run_list(
134 135
135/// fetches branches and tags from git servers so patch parent commits can be 136/// fetches branches and tags from git servers so patch parent commits can be
136/// used to build patches with correct commit ids 137/// used to build patches with correct commit ids
138#[allow(clippy::too_many_lines)]
137async fn get_open_and_draft_proposals_state( 139async fn get_open_and_draft_proposals_state(
138 term: &console::Term, 140 term: &console::Term,
139 git_repo: &Repo, 141 git_repo: &Repo,
@@ -156,8 +158,6 @@ async fn get_open_and_draft_proposals_state(
156 .filter(|v| !v.starts_with("ref: ")) 158 .filter(|v| !v.starts_with("ref: "))
157 .cloned() 159 .cloned()
158 .collect::<Vec<String>>(), 160 .collect::<Vec<String>>(),
159 // TODO we could fetch the oids of Pull Requests and Pull Request Updates to prevent
160 // having repeat fetching during the git remote helper fetch phase
161 git_server_url, 161 git_server_url,
162 &repo_ref.to_nostr_git_url(&None), 162 &repo_ref.to_nostr_git_url(&None),
163 term, 163 term,
@@ -169,8 +169,62 @@ async fn get_open_and_draft_proposals_state(
169 } 169 }
170 } 170 }
171 171
172 let mut state = HashMap::new();
173 let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?; 172 let open_and_draft_proposals = get_open_or_draft_proposals(git_repo, repo_ref).await?;
173
174 // Collect PR/PR-update tip OIDs that are still missing after the bulk prefetch.
175 // We borrow proposals here so we can move them in the state-building loop
176 // below.
177 let mut missing_pr_oids: Vec<String> = open_and_draft_proposals
178 .values()
179 .filter_map(|(_, events)| {
180 events
181 .iter()
182 .find(|e| e.kind.eq(&KIND_PULL_REQUEST) || e.kind.eq(&KIND_PULL_REQUEST_UPDATE))
183 .and_then(|e| tag_value(e, "c").ok())
184 })
185 .filter(|tip| !git_repo.does_commit_exist(tip).unwrap_or(false))
186 .collect();
187
188 // For each repo git server, batch-fetch the PR tip OIDs it carries that are
189 // still missing locally. Only OIDs the server has advertised are included in
190 // each batch (avoids all-or-nothing batch-poisoning). We mop up across servers
191 // until all missing OIDs are satisfied or all servers are exhausted.
192 //
193 // NOTE: we intentionally restrict mop-up to the repo's declared git servers
194 // (remote_states) and do NOT try the git-server URL carried in the PR event's
195 // `clone` tag. A PR submitter could include an arbitrary server URL there;
196 // fetching from it unconditionally would let a malicious or slow server
197 // delay every clone/fetch. If we later want to support PR-supplied servers,
198 // it should be opt-in (e.g. an explicit `--include-pr-servers` flag) so
199 // users consciously accept the trust/performance trade-off. PRs whose tip
200 // OID isn't carried by any repo git server will simply not be advertised as
201 // `refs/heads/pr/*` refs; they are still accessible via their patch events.
202 if !missing_pr_oids.is_empty() {
203 for (server_url, (server_state, is_grasp)) in remote_states {
204 let batch: Vec<String> = missing_pr_oids
205 .iter()
206 .filter(|oid| server_state.values().any(|v| v == *oid))
207 .cloned()
208 .collect();
209 if batch.is_empty() {
210 continue;
211 }
212 let _ = fetch_from_git_server(
213 git_repo,
214 &batch,
215 server_url,
216 &repo_ref.to_nostr_git_url(&None),
217 term,
218 *is_grasp,
219 );
220 missing_pr_oids.retain(|oid| !git_repo.does_commit_exist(oid).unwrap_or(false));
221 if missing_pr_oids.is_empty() {
222 break;
223 }
224 }
225 }
226
227 let mut state = HashMap::new();
174 let current_user = get_curent_user(git_repo)?; 228 let current_user = get_curent_user(git_repo)?;
175 for (_, (proposal, events_to_apply)) in open_and_draft_proposals { 229 for (_, (proposal, events_to_apply)) in open_and_draft_proposals {
176 if let Ok(cl) = event_to_cover_letter(&proposal) { 230 if let Ok(cl) = event_to_cover_letter(&proposal) {
@@ -192,17 +246,9 @@ async fn get_open_and_draft_proposals_state(
192 { 246 {
193 match tag_value(pr_or_pr_update, "c") { 247 match tag_value(pr_or_pr_update, "c") {
194 Ok(tip) => { 248 Ok(tip) => {
195 // only list Pull Requests as refs/heads/pr/* if data is commit is 249 // Only advertise once confirmed locally available — this
196 // advertised as tip of a ref on a repo git server or 250 // guarantees the subsequent fetch phase can serve the object.
197 // available locally. Otherwise the standard cmd: 251 if git_repo.does_commit_exist(&tip).is_ok_and(|r| r) {
198 // `git clone nostr://` will fail as it assumes all /refs/heads
199 // returned by list are accessable
200 let tip_oid_is_on_a_repo_git_server =
201 remote_states.iter().any(|(_url, (state, _is_grasp))| {
202 state.iter().any(|(_, oid)| tip == *oid)
203 }) || git_repo.does_commit_exist(&tip).is_ok_and(|r| r);
204
205 if tip_oid_is_on_a_repo_git_server {
206 state.insert(format!("refs/heads/{branch_name}"), tip); 252 state.insert(format!("refs/heads/{branch_name}"), tip);
207 } 253 }
208 } 254 }
@@ -254,8 +300,8 @@ async fn get_all_proposals_state(
254 let mut state = HashMap::new(); 300 let mut state = HashMap::new();
255 let all_proposals = get_all_proposals(git_repo, repo_ref).await?; 301 let all_proposals = get_all_proposals(git_repo, repo_ref).await?;
256 let current_user = get_curent_user(git_repo)?; 302 let current_user = get_curent_user(git_repo)?;
257 for (_, (proposal, events_to_apply)) in all_proposals { 303 for (proposal, events_to_apply) in all_proposals.values() {
258 if let Ok(cl) = event_to_cover_letter(&proposal) { 304 if let Ok(cl) = event_to_cover_letter(proposal) {
259 if let Ok(mut branch_name) = cl.get_branch_name_with_pr_prefix_and_shorthand_id() { 305 if let Ok(mut branch_name) = cl.get_branch_name_with_pr_prefix_and_shorthand_id() {
260 branch_name = if let Some(public_key) = current_user { 306 branch_name = if let Some(public_key) = current_user {
261 if proposal.pubkey.eq(&public_key) { 307 if proposal.pubkey.eq(&public_key) {
@@ -275,7 +321,7 @@ async fn get_all_proposals_state(
275 state.insert(format!("refs/pr/{}/head", proposal.id), tip); 321 state.insert(format!("refs/pr/{}/head", proposal.id), tip);
276 } 322 }
277 } else if let Ok(tip) = 323 } else if let Ok(tip) =
278 make_commits_for_proposal(git_repo, repo_ref, &events_to_apply) 324 make_commits_for_proposal(git_repo, repo_ref, events_to_apply)
279 { 325 {
280 state.insert(format!("refs/{branch_name}"), tip.clone()); 326 state.insert(format!("refs/{branch_name}"), tip.clone());
281 state.insert(format!("refs/pr/{}/head", proposal.id), tip); 327 state.insert(format!("refs/pr/{}/head", proposal.id), tip);