upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/git
diff options
context:
space:
mode:
Diffstat (limited to 'src/git')
-rw-r--r--src/git/authorization.rs367
-rw-r--r--src/git/handlers.rs104
2 files changed, 403 insertions, 68 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs
index 16498c1..1be3de9 100644
--- a/src/git/authorization.rs
+++ b/src/git/authorization.rs
@@ -9,7 +9,7 @@
9//! 9//!
10//! ## Authorization Flow (Efficient Single-Query Approach) 10//! ## Authorization Flow (Efficient Single-Query Approach)
11//! 11//!
12//! 1. Fetch announcement and state events for the repository from the relay 12//! 1. Fetch announcement and state events for the repository from the relay database
13//! 2. Collect all authorized publishers: announcement authors + listed maintainers 13//! 2. Collect all authorized publishers: announcement authors + listed maintainers
14//! 3. Find the latest state event authored by any authorized publisher 14//! 3. Find the latest state event authored by any authorized publisher
15//! 4. Validate that the pushed refs match the state event 15//! 4. Validate that the pushed refs match the state event
@@ -20,16 +20,377 @@
20//! same identifier: 20//! same identifier:
21//! - They are the author of that announcement, OR 21//! - They are the author of that announcement, OR
22//! - They are listed in the "maintainers" tag of that announcement 22//! - They are listed in the "maintainers" tag of that announcement
23//!
24//! ## Shared Helper Functions
25//!
26//! This module provides helper functions that can be used by both:
27//! - Git push authorization in handlers.rs
28//! - HEAD updates triggered by state events in builder.rs (event policy)
23 29
24use anyhow::{anyhow, Result}; 30use anyhow::{anyhow, Result};
25use nostr_sdk::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag, Timestamp, ToBech32}; 31use nostr_relay_builder::prelude::*;
26use std::collections::HashSet; 32use nostr_sdk::ToBech32;
33use std::collections::{HashMap, HashSet};
34use std::sync::Arc;
27use tracing::debug; 35use tracing::debug;
28 36
29use crate::nostr::events::{ 37use crate::nostr::events::{
30 RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 38 RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE,
31}; 39};
32 40
41/// Repository data fetched from the database
42///
43/// Contains all announcements and states for a given identifier,
44/// fetched with a single filter query.
45#[derive(Debug)]
46pub struct RepositoryData {
47 /// All repository announcements with this identifier
48 pub announcements: Vec<RepositoryAnnouncement>,
49 /// All repository state events with this identifier
50 pub states: Vec<RepositoryState>,
51}
52
53/// Fetch all repository data (announcements + states) for a given identifier
54///
55/// This performs a single database query to fetch both announcement and state events,
56/// which is more efficient than separate queries.
57pub async fn fetch_repository_data(
58 database: &Arc<MemoryDatabase>,
59 identifier: &str,
60) -> Result<RepositoryData> {
61 let filter = Filter::new()
62 .kinds([
63 Kind::from(KIND_REPOSITORY_ANNOUNCEMENT),
64 Kind::from(KIND_REPOSITORY_STATE),
65 ])
66 .custom_tag(
67 SingleLetterTag::lowercase(Alphabet::D),
68 identifier.to_string(),
69 );
70
71 let events: Vec<Event> = database
72 .query(filter)
73 .await
74 .map_err(|e| anyhow!("Database query failed: {}", e))?
75 .into_iter()
76 .collect();
77
78 debug!(
79 "Fetched {} events for identifier {} from database",
80 events.len(),
81 identifier
82 );
83
84 // Separate into announcements and states
85 let mut announcements = Vec::new();
86 let mut states = Vec::new();
87
88 for event in events {
89 if event.kind == Kind::from(KIND_REPOSITORY_ANNOUNCEMENT) {
90 if let Ok(announcement) = RepositoryAnnouncement::from_event(event) {
91 announcements.push(announcement);
92 }
93 } else if event.kind == Kind::from(KIND_REPOSITORY_STATE) {
94 if let Ok(state) = RepositoryState::from_event(event) {
95 states.push(state);
96 }
97 }
98 }
99
100 debug!(
101 "Parsed {} announcements and {} states for identifier {}",
102 announcements.len(),
103 states.len(),
104 identifier
105 );
106
107 Ok(RepositoryData {
108 announcements,
109 states,
110 })
111}
112
113/// Collect authorized maintainers grouped by owner from a set of announcements
114///
115/// For each announcement, returns a map from owner pubkey to authorized maintainers:
116/// - The owner is always included in their own list
117/// - All pubkeys listed in the "maintainers" tag are also included
118/// - **Recursively**: if a maintainer also has an announcement for the same identifier,
119/// their maintainers are included too (transitive closure)
120///
121/// This allows looking up who can publish state events for a specific owner's
122/// version of the repository.
123///
124/// ## Example
125///
126/// If Alice's announcement lists Bob as maintainer, and Bob's announcement (for the
127/// same identifier) lists Charlie as maintainer, then Alice's authorized set will
128/// be {Alice, Bob, Charlie}.
129pub fn collect_authorized_maintainers(
130 announcements: &[RepositoryAnnouncement],
131) -> HashMap<String, Vec<String>> {
132 let mut by_owner: HashMap<String, Vec<String>> = HashMap::new();
133
134 for announcement in announcements {
135 let owner = announcement.event.pubkey.to_hex();
136 let identifier = &announcement.identifier;
137
138 // Use recursive helper to get all maintainers
139 let mut checked: HashSet<String> = HashSet::new();
140 get_maintainers_recursive(announcements, &owner, identifier, &mut checked);
141
142 by_owner.insert(owner, checked.into_iter().collect());
143 }
144
145 debug!(
146 "Collected maintainers for {} owners from {} announcements (with recursive expansion)",
147 by_owner.len(),
148 announcements.len()
149 );
150
151 by_owner
152}
153
154/// Recursively find all maintainers starting from a pubkey
155///
156/// This follows the pattern from ngit-relay's GetMaintainers function:
157/// - If pubkey already checked, return early (cycle prevention)
158/// - Mark pubkey as checked
159/// - Find the announcement for this pubkey+identifier
160/// - Recursively call for each maintainer listed in that announcement
161/// - The `checked` set accumulates all visited pubkeys
162fn get_maintainers_recursive(
163 announcements: &[RepositoryAnnouncement],
164 pubkey: &str,
165 identifier: &str,
166 checked: &mut HashSet<String>,
167) {
168 // Check if this pubkey has already been processed
169 if checked.contains(pubkey) {
170 return; // Already checked - avoid cycles
171 }
172 checked.insert(pubkey.to_string()); // Mark as checked
173
174 // Find the announcement event for this pubkey+identifier
175 let announcement = announcements.iter().find(|a| {
176 a.event.pubkey.to_hex() == pubkey && a.identifier == identifier
177 });
178
179 let Some(announcement) = announcement else {
180 return; // No announcement found for this pubkey
181 };
182
183 // Recursively find maintainers for each listed maintainer
184 for maintainer_pubkey in &announcement.maintainers {
185 get_maintainers_recursive(announcements, maintainer_pubkey, identifier, checked);
186 }
187}
188
189/// Collect all authorized maintainers as a flat set from all announcements
190///
191/// This is a convenience function that flattens the per-owner maintainer lists
192/// into a single set. Use this when you don't need owner-specific authorization.
193pub fn collect_all_authorized_maintainers(
194 announcements: &[RepositoryAnnouncement],
195) -> HashSet<String> {
196 let by_owner = collect_authorized_maintainers(announcements);
197 let mut all_authorized = HashSet::new();
198
199 for maintainers in by_owner.values() {
200 for maintainer in maintainers {
201 all_authorized.insert(maintainer.clone());
202 }
203 }
204
205 debug!(
206 "Collected {} total authorized maintainers from {} owners",
207 all_authorized.len(),
208 by_owner.len()
209 );
210
211 all_authorized
212}
213
214/// Find the latest state event authored by an authorized maintainer
215///
216/// Returns the state with the highest created_at timestamp among those
217/// authored by pubkeys in the authorized set.
218pub fn find_latest_authorized_state<'a>(
219 states: &'a [RepositoryState],
220 authorized_pubkeys: &HashSet<String>,
221) -> Option<&'a RepositoryState> {
222 states
223 .iter()
224 .filter(|s| {
225 let pubkey_hex = s.event.pubkey.to_hex();
226 authorized_pubkeys.contains(&pubkey_hex)
227 })
228 .max_by_key(|s| s.event.created_at)
229}
230
231/// Find the latest authorized state for a specific announcement context
232///
233/// This is similar to `find_latest_authorized_state` but considers only
234/// the maintainers authorized for a specific announcement (owner + maintainers),
235/// not the global set across all announcements.
236pub fn find_latest_state_for_announcement<'a>(
237 states: &'a [RepositoryState],
238 announcement: &RepositoryAnnouncement,
239) -> Option<&'a RepositoryState> {
240 // Build the authorized set for this specific announcement
241 let mut authorized = HashSet::new();
242 authorized.insert(announcement.event.pubkey.to_hex());
243 for maintainer in &announcement.maintainers {
244 authorized.insert(maintainer.clone());
245 }
246
247 find_latest_authorized_state(states, &authorized)
248}
249
250/// Check if a state event is the latest for its identifier among given authorized authors
251///
252/// A state is considered "latest" if no other state in the provided list
253/// from an authorized author has a newer timestamp.
254pub fn is_latest_state(
255 state: &RepositoryState,
256 all_states: &[RepositoryState],
257 authorized_pubkeys: &HashSet<String>,
258) -> bool {
259 for other in all_states {
260 // Skip self
261 if other.event.id == state.event.id {
262 continue;
263 }
264 // Only compare against authorized authors
265 if !authorized_pubkeys.contains(&other.event.pubkey.to_hex()) {
266 continue;
267 }
268 // If any authorized state is newer, this is not the latest
269 if other.event.created_at > state.event.created_at {
270 return false;
271 }
272 }
273 true
274}
275
276/// Get the authorization result for a repository from the database
277///
278/// This is the main entry point for authorization that queries the database directly.
279/// It:
280/// 1. Fetches all announcements and states for the identifier with a single query
281/// 2. Collects all authorized maintainers from announcements
282/// 3. Finds the latest state event from an authorized maintainer
283///
284/// Returns an `AuthorizationResult` that indicates whether a push is authorized.
285pub async fn get_authorization_from_db(
286 database: &Arc<MemoryDatabase>,
287 identifier: &str,
288) -> Result<AuthorizationResult> {
289 // Fetch all repository data with a single query
290 let repo_data = fetch_repository_data(database, identifier).await?;
291
292 if repo_data.announcements.is_empty() {
293 return Ok(AuthorizationResult::denied(
294 "No repository announcement found",
295 ));
296 }
297
298 // Collect all authorized maintainers (flattened across all owners)
299 let authorized = collect_all_authorized_maintainers(&repo_data.announcements);
300
301 if authorized.is_empty() {
302 return Ok(AuthorizationResult::denied(
303 "No authorized maintainers found",
304 ));
305 }
306
307 debug!(
308 "Found {} authorized maintainers for repository {}",
309 authorized.len(),
310 identifier
311 );
312
313 // Find the latest authorized state
314 match find_latest_authorized_state(&repo_data.states, &authorized) {
315 Some(state) => Ok(AuthorizationResult::authorized(
316 state.clone(),
317 authorized.into_iter().collect(),
318 )),
319 None => Ok(AuthorizationResult::denied(
320 "No state event found from authorized publishers",
321 )),
322 }
323}
324
325/// Get the authorization result for a repository scoped to a specific owner
326///
327/// Unlike `get_authorization_from_db`, this function scopes the authorization
328/// to a specific owner's announcement. This is the correct approach for Git push
329/// authorization where the URL path specifies the owner.
330///
331/// A push to `alice/my-repo` should only consider authorization from alice's
332/// announcement, not bob's announcement for the same identifier.
333///
334/// It:
335/// 1. Fetches all announcements and states for the identifier
336/// 2. Collects authorized maintainers from all announcements (grouped by owner)
337/// 3. Looks up the authorized set for the specific owner
338/// 4. Finds the latest state event from an authorized maintainer
339///
340/// Returns an `AuthorizationResult` that indicates whether a push is authorized.
341pub async fn get_authorization_for_owner(
342 database: &Arc<MemoryDatabase>,
343 identifier: &str,
344 owner_pubkey: &str,
345) -> Result<AuthorizationResult> {
346 // Fetch all repository data with a single query
347 let repo_data = fetch_repository_data(database, identifier).await?;
348
349 if repo_data.announcements.is_empty() {
350 return Ok(AuthorizationResult::denied(
351 "No repository announcement found",
352 ));
353 }
354
355 // Collect authorized maintainers grouped by owner from all announcements
356 let by_owner = collect_authorized_maintainers(&repo_data.announcements);
357
358 // Look up the authorized set for this specific owner
359 let authorized: HashSet<String> = match by_owner.get(owner_pubkey) {
360 Some(maintainers) => maintainers.iter().cloned().collect(),
361 None => {
362 return Ok(AuthorizationResult::denied(format!(
363 "No repository announcement found for owner {}",
364 owner_pubkey
365 )));
366 }
367 };
368
369 if authorized.is_empty() {
370 return Ok(AuthorizationResult::denied(
371 "No authorized maintainers found",
372 ));
373 }
374
375 debug!(
376 "Found {} authorized maintainers for repository {} (owner: {})",
377 authorized.len(),
378 identifier,
379 owner_pubkey
380 );
381
382 // Find the latest authorized state from owner's maintainer set
383 match find_latest_authorized_state(&repo_data.states, &authorized) {
384 Some(state) => Ok(AuthorizationResult::authorized(
385 state.clone(),
386 authorized.into_iter().collect(),
387 )),
388 None => Ok(AuthorizationResult::denied(
389 "No state event found from authorized publishers",
390 )),
391 }
392}
393
33/// Result of authorization check 394/// Result of authorization check
34#[derive(Debug)] 395#[derive(Debug)]
35pub struct AuthorizationResult { 396pub struct AuthorizationResult {
diff --git a/src/git/handlers.rs b/src/git/handlers.rs
index 73f72f3..7974d8a 100644
--- a/src/git/handlers.rs
+++ b/src/git/handlers.rs
@@ -3,17 +3,19 @@
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
5use std::path::PathBuf; 5use std::path::PathBuf;
6use std::sync::Arc;
6use hyper::{body::Bytes, Response, StatusCode}; 7use hyper::{body::Bytes, Response, StatusCode};
7use http_body_util::Full; 8use http_body_util::Full;
9use nostr_relay_builder::prelude::MemoryDatabase;
8use tokio::io::{AsyncReadExt, AsyncWriteExt}; 10use tokio::io::{AsyncReadExt, AsyncWriteExt};
9use tracing::{debug, error, info, warn}; 11use tracing::{debug, error, info, warn};
10 12
11use super::authorization::{ 13use super::authorization::{
12 AuthorizationContext, AuthorizationResult, parse_pushed_refs, validate_push_refs, 14 get_authorization_for_owner, parse_pushed_refs, validate_push_refs, AuthorizationResult,
13}; 15};
14use super::protocol::{GitService, PktLine}; 16use super::protocol::{GitService, PktLine};
15use super::subprocess::GitSubprocess; 17use super::subprocess::GitSubprocess;
16use super::{try_set_head_if_available}; 18use super::try_set_head_if_available;
17 19
18use crate::nostr::events::RepositoryState; 20use crate::nostr::events::RepositoryState;
19 21
@@ -150,17 +152,6 @@ pub async fn handle_upload_pack(
150 .unwrap()) 152 .unwrap())
151} 153}
152 154
153/// Authorization parameters for push operations
154#[derive(Debug, Clone)]
155pub struct PushAuthParams {
156 /// The relay URL for fetching events (e.g., "ws://localhost:8080")
157 pub relay_url: String,
158 /// The npub of the repository owner
159 pub owner_npub: String,
160 /// The repository identifier (d tag)
161 pub identifier: String,
162}
163
164/// Handle POST /git-receive-pack (push) 155/// Handle POST /git-receive-pack (push)
165/// 156///
166/// This includes GRASP authorization validation according to GRASP-01: 157/// This includes GRASP authorization validation according to GRASP-01:
@@ -169,10 +160,19 @@ pub struct PushAuthParams {
169/// 160///
170/// Also per GRASP-01: "MUST set repository HEAD per repository state announcement 161/// Also per GRASP-01: "MUST set repository HEAD per repository state announcement
171/// as soon as the git data related to that branch has been received." 162/// as soon as the git data related to that branch has been received."
163///
164/// # Arguments
165/// * `repo_path` - Path to the bare git repository
166/// * `request_body` - The git pack data from the client
167/// * `database` - Optional database reference for authorization queries
168/// * `identifier` - The repository identifier (d tag) for authorization lookup
169/// * `owner_pubkey` - The owner's public key (hex) from the URL path, scoping authorization
172pub async fn handle_receive_pack( 170pub async fn handle_receive_pack(
173 repo_path: PathBuf, 171 repo_path: PathBuf,
174 request_body: Bytes, 172 request_body: Bytes,
175 auth_params: Option<PushAuthParams>, 173 database: Option<Arc<MemoryDatabase>>,
174 identifier: &str,
175 owner_pubkey: &str,
176) -> Result<Response<Full<Bytes>>, GitError> { 176) -> Result<Response<Full<Bytes>>, GitError> {
177 debug!("Handling receive-pack for {:?}", repo_path); 177 debug!("Handling receive-pack for {:?}", repo_path);
178 178
@@ -183,26 +183,25 @@ pub async fn handle_receive_pack(
183 // Keep track of state for HEAD setting after push 183 // Keep track of state for HEAD setting after push
184 let mut authorized_state: Option<RepositoryState> = None; 184 let mut authorized_state: Option<RepositoryState> = None;
185 185
186 // GRASP Authorization Check 186 // GRASP Authorization Check (if database is provided)
187 if let Some(ref params) = auth_params { 187 if let Some(ref db) = database {
188 info!( 188 info!(
189 "Authorizing push for {}/{} via {}", 189 "Authorizing push for {} owned by {} via database query",
190 params.owner_npub, params.identifier, params.relay_url 190 identifier, owner_pubkey
191 ); 191 );
192 192
193 match authorize_push(params, &request_body).await { 193 match authorize_push(db, identifier, owner_pubkey, &request_body).await {
194 Ok(auth_result) => { 194 Ok(auth_result) => {
195 if !auth_result.authorized { 195 if !auth_result.authorized {
196 warn!( 196 warn!(
197 "Push rejected for {}/{}: {}", 197 "Push rejected for {}: {}",
198 params.owner_npub, params.identifier, auth_result.reason 198 identifier, auth_result.reason
199 ); 199 );
200 return Err(GitError::Unauthorized); 200 return Err(GitError::Unauthorized);
201 } 201 }
202 info!( 202 info!(
203 "Push authorized for {}/{} - {} maintainers", 203 "Push authorized for {} - {} maintainers",
204 params.owner_npub, 204 identifier,
205 params.identifier,
206 auth_result.maintainers.len() 205 auth_result.maintainers.len()
207 ); 206 );
208 // Save the state for HEAD setting after push 207 // Save the state for HEAD setting after push
@@ -210,14 +209,14 @@ pub async fn handle_receive_pack(
210 } 209 }
211 Err(e) => { 210 Err(e) => {
212 warn!( 211 warn!(
213 "Authorization check failed for {}/{}: {}", 212 "Authorization check failed for {}: {}",
214 params.owner_npub, params.identifier, e 213 identifier, e
215 ); 214 );
216 return Err(GitError::Unauthorized); 215 return Err(GitError::Unauthorized);
217 } 216 }
218 } 217 }
219 } else { 218 } else {
220 debug!("No authorization parameters provided - accepting push"); 219 debug!("No database provided - accepting push without authorization");
221 } 220 }
222 221
223 // Spawn git receive-pack 222 // Spawn git receive-pack
@@ -299,50 +298,25 @@ pub async fn handle_receive_pack(
299 298
300/// Perform GRASP authorization for a push operation 299/// Perform GRASP authorization for a push operation
301/// 300///
302/// This function: 301/// This function queries the database directly (not via WebSocket):
303/// 1. Fetches announcement and state events from the relay 302/// 1. Fetches announcement and state events for the identifier
304/// 2. Collects all authorized publishers from announcements 303/// 2. Filters to the specific owner's announcement
305/// 3. Gets the latest authorized state 304/// 3. Collects authorized publishers from that announcement (owner + maintainers)
306/// 4. Validates that pushed refs match the state 305/// 4. Gets the latest authorized state from those publishers
306/// 5. Validates that pushed refs match the state
307async fn authorize_push( 307async fn authorize_push(
308 params: &PushAuthParams, 308 database: &Arc<MemoryDatabase>,
309 identifier: &str,
310 owner_pubkey: &str,
309 request_body: &Bytes, 311 request_body: &Bytes,
310) -> anyhow::Result<AuthorizationResult> { 312) -> anyhow::Result<AuthorizationResult> {
311 use nostr_sdk::ClientBuilder;
312 use std::time::Duration;
313
314 debug!( 313 debug!(
315 "Fetching events for identifier {} from relay {}", 314 "Authorizing push for {} owned by {} via database query",
316 params.identifier, params.relay_url 315 identifier, owner_pubkey
317 ); 316 );
318 317
319 // Create a Nostr client to fetch events 318 // Get authorization result from database, scoped to specific owner
320 let client = ClientBuilder::default().build(); 319 let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?;
321 client.add_relay(&params.relay_url).await?;
322 client.connect().await;
323
324 // Create filter for repository events
325 let filter = AuthorizationContext::create_filter(&params.identifier);
326
327 // Fetch events with timeout
328 let events = client.fetch_events(filter, Duration::from_secs(5))
329 .await
330 .map_err(|e| anyhow::anyhow!("Failed to fetch events: {}", e))?;
331
332 let events: Vec<_> = events.into_iter().collect();
333 debug!("Fetched {} events from relay", events.len());
334
335 if events.is_empty() {
336 return Ok(AuthorizationResult::denied(
337 "No repository announcement or state events found on relay",
338 ));
339 }
340
341 // Create authorization context
342 let ctx = AuthorizationContext::new(events);
343
344 // Get the authorized state (no owner_pubkey needed - self-contained check)
345 let auth_result = ctx.get_authorized_state(&params.identifier)?;
346 320
347 if !auth_result.authorized { 321 if !auth_result.authorized {
348 return Ok(auth_result); 322 return Ok(auth_result);