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:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-01 11:56:49 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-01 11:58:34 +0000
commit7a78815e29b01c83f3d0ec195ba717a2eba8cd37 (patch)
tree4c5ccd9b812f1d1d75ed218501192ddc5459fd12 /src/git
parente6ceab90de1acad154624022a6036efac18abab6 (diff)
reject push when refs/nostr/<event-id> doesnt match known event and delete incorrect ref on event receive
Diffstat (limited to 'src/git')
-rw-r--r--src/git/authorization.rs142
-rw-r--r--src/git/handlers.rs211
-rw-r--r--src/git/mod.rs210
3 files changed, 403 insertions, 160 deletions
diff --git a/src/git/authorization.rs b/src/git/authorization.rs
index bb3bd01..3b0e759 100644
--- a/src/git/authorization.rs
+++ b/src/git/authorization.rs
@@ -35,7 +35,8 @@ use std::sync::Arc;
35use tracing::debug; 35use tracing::debug;
36 36
37use crate::nostr::events::{ 37use crate::nostr::events::{
38 RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 38 RepositoryAnnouncement, RepositoryState, KIND_PR, KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT,
39 KIND_REPOSITORY_STATE,
39}; 40};
40 41
41/// Repository data fetched from the database 42/// Repository data fetched from the database
@@ -172,9 +173,9 @@ fn get_maintainers_recursive(
172 checked.insert(pubkey.to_string()); // Mark as checked 173 checked.insert(pubkey.to_string()); // Mark as checked
173 174
174 // Find the announcement event for this pubkey+identifier 175 // Find the announcement event for this pubkey+identifier
175 let announcement = announcements.iter().find(|a| { 176 let announcement = announcements
176 a.event.pubkey.to_hex() == pubkey && a.identifier == identifier 177 .iter()
177 }); 178 .find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier);
178 179
179 let Some(announcement) = announcement else { 180 let Some(announcement) = announcement else {
180 return; // No announcement found for this pubkey 181 return; // No announcement found for this pubkey
@@ -195,19 +196,19 @@ pub fn collect_all_authorized_maintainers(
195) -> HashSet<String> { 196) -> HashSet<String> {
196 let by_owner = collect_authorized_maintainers(announcements); 197 let by_owner = collect_authorized_maintainers(announcements);
197 let mut all_authorized = HashSet::new(); 198 let mut all_authorized = HashSet::new();
198 199
199 for maintainers in by_owner.values() { 200 for maintainers in by_owner.values() {
200 for maintainer in maintainers { 201 for maintainer in maintainers {
201 all_authorized.insert(maintainer.clone()); 202 all_authorized.insert(maintainer.clone());
202 } 203 }
203 } 204 }
204 205
205 debug!( 206 debug!(
206 "Collected {} total authorized maintainers from {} owners", 207 "Collected {} total authorized maintainers from {} owners",
207 all_authorized.len(), 208 all_authorized.len(),
208 by_owner.len() 209 by_owner.len()
209 ); 210 );
210 211
211 all_authorized 212 all_authorized
212} 213}
213 214
@@ -601,10 +602,7 @@ pub fn validate_push_refs(
601 pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name) 602 pushed_refs: &[(String, String, String)], // (old_oid, new_oid, ref_name)
602) -> Result<()> { 603) -> Result<()> {
603 for (old_oid, new_oid, ref_name) in pushed_refs { 604 for (old_oid, new_oid, ref_name) in pushed_refs {
604 debug!( 605 debug!("Validating push: {} {} -> {}", ref_name, old_oid, new_oid);
605 "Validating push: {} {} -> {}",
606 ref_name, old_oid, new_oid
607 );
608 606
609 // Handle branch updates 607 // Handle branch updates
610 if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") { 608 if let Some(branch_name) = ref_name.strip_prefix("refs/heads/") {
@@ -657,7 +655,10 @@ pub fn validate_push_refs(
657 )); 655 ));
658 } 656 }
659 // Valid EventId format - allow push (skip state event check) 657 // Valid EventId format - allow push (skip state event check)
660 debug!("refs/nostr/{} push authorized (valid EventId)", event_id_str); 658 debug!(
659 "refs/nostr/{} push authorized (valid EventId)",
660 event_id_str
661 );
661 continue; // Skip the rest of ref validation for this ref 662 continue; // Skip the rest of ref validation for this ref
662 } else { 663 } else {
663 return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name)); 664 return Err(anyhow!("Invalid refs/nostr/ format: {}", ref_name));
@@ -805,6 +806,119 @@ pub fn npub_to_pubkey(npub: &str) -> Result<String> {
805 Ok(pk.to_hex()) 806 Ok(pk.to_hex())
806} 807}
807 808
809/// Fetch an event by ID from the database and extract the `c` tag commit hash
810///
811/// This is used for validating pushes to refs/nostr/<event-id>. Per GRASP-01,
812/// if a PR or PR Update event with this ID exists in the database, the pushed
813/// commit must match the commit in the event's `c` tag.
814///
815/// # Returns
816/// - `Ok(Some(commit))` if the event exists and has a valid `c` tag
817/// - `Ok(None)` if the event doesn't exist (push should be allowed)
818/// - `Err(_)` on database errors
819pub async fn get_event_commit_tag(
820 database: &Arc<MemoryDatabase>,
821 event_id: &EventId,
822) -> Result<Option<String>> {
823 // Query for PR (1618) and PR Update (1619) events with this ID
824 let filter = Filter::new()
825 .ids([*event_id])
826 .kinds([Kind::from(KIND_PR), Kind::from(KIND_PR_UPDATE)]);
827
828 let events: Vec<Event> = database
829 .query(filter)
830 .await
831 .map_err(|e| anyhow!("Database query failed: {}", e))?
832 .into_iter()
833 .collect();
834
835 if events.is_empty() {
836 debug!("No PR/PR Update event found with ID {}", event_id);
837 return Ok(None);
838 }
839
840 // Get the first (should be only) event
841 let event = &events[0];
842
843 // Extract the `c` tag (commit hash)
844 // Per NIP-34, PR events have a `c` tag with the head commit
845 let commit = event
846 .tags
847 .iter()
848 .find(|tag| tag.as_slice().first().map(|s| s.as_str()) == Some("c"))
849 .and_then(|tag| tag.as_slice().get(1).map(|s| s.to_string()));
850
851 debug!(
852 "Found PR event {} with commit tag: {:?}",
853 event_id,
854 commit.as_ref()
855 );
856
857 Ok(commit)
858}
859
860/// Validate refs/nostr/ pushes against existing PR/PR Update events
861///
862/// For each ref being pushed to refs/nostr/<event-id>:
863/// 1. Validate the event ID format (error if invalid)
864/// 2. Check if a corresponding event exists in the database
865/// 3. If event exists, verify the pushed commit matches the `c` tag
866///
867/// # Arguments
868/// * `database` - The nostr database to query
869/// * `pushed_refs` - List of (old_oid, new_oid, ref_name) tuples
870///
871/// # Returns
872/// * `Ok(())` if all refs/nostr/ pushes are valid
873/// * `Err(_)` if any ref has invalid event ID format or fails commit validation
874pub async fn validate_nostr_ref_pushes(
875 database: &Arc<MemoryDatabase>,
876 pushed_refs: &[(String, String, String)],
877) -> Result<()> {
878 for (_, new_oid, ref_name) in pushed_refs {
879 // Only check refs/nostr/ refs
880 if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") {
881 // Parse the event ID - error on invalid format
882 let event_id = EventId::parse(event_id_str).map_err(|_| {
883 anyhow!(
884 "Invalid event ID format '{}' in ref: {}",
885 event_id_str,
886 ref_name
887 )
888 })?;
889
890 // Check if event exists and get commit tag
891 match get_event_commit_tag(database, &event_id).await? {
892 Some(expected_commit) => {
893 // Event exists - verify commit matches
894 if new_oid != &expected_commit {
895 return Err(anyhow!(
896 "Push to {} rejected: event {} specifies commit {}, but push contains {}",
897 ref_name,
898 event_id_str,
899 expected_commit,
900 new_oid
901 ));
902 }
903 debug!(
904 "Push to {} validated: commit {} matches event's c tag",
905 ref_name, new_oid
906 );
907 }
908 None => {
909 // No event exists yet - allow push
910 debug!(
911 "Push to {} allowed: no PR/PR Update event with ID {} found yet",
912 ref_name, event_id_str
913 );
914 }
915 }
916 }
917 }
918
919 Ok(())
920}
921
808#[cfg(test)] 922#[cfg(test)]
809mod tests { 923mod tests {
810 use super::*; 924 use super::*;
@@ -920,7 +1034,7 @@ mod tests {
920 let eve = create_test_keys(); // Not authorized 1034 let eve = create_test_keys(); // Not authorized
921 let identifier = "test-repo"; 1035 let identifier = "test-repo";
922 1036
923 // Alice lists Bob as maintainer 1037 // Alice lists Bob as maintainer
924 let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]); 1038 let alice_announcement = create_announcement_event(&alice, identifier, &[&bob]);
925 1039
926 let events = vec![alice_announcement]; 1040 let events = vec![alice_announcement];
@@ -1084,4 +1198,4 @@ mod tests {
1084 let back_to_hex = npub_to_pubkey(&npub).unwrap(); 1198 let back_to_hex = npub_to_pubkey(&npub).unwrap();
1085 assert_eq!(hex, back_to_hex); 1199 assert_eq!(hex, back_to_hex);
1086 } 1200 }
1087} \ No newline at end of file 1201}
diff --git a/src/git/handlers.rs b/src/git/handlers.rs
index 23d4b5b..00f2449 100644
--- a/src/git/handlers.rs
+++ b/src/git/handlers.rs
@@ -2,17 +2,18 @@
2//! 2//!
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;
6use std::sync::Arc;
7use hyper::{body::Bytes, Response, StatusCode};
8use http_body_util::Full; 5use http_body_util::Full;
6use hyper::{body::Bytes, Response, StatusCode};
9use nostr_relay_builder::prelude::MemoryDatabase; 7use nostr_relay_builder::prelude::MemoryDatabase;
10use nostr_sdk::EventId; 8use nostr_sdk::EventId;
9use std::path::PathBuf;
10use std::sync::Arc;
11use tokio::io::{AsyncReadExt, AsyncWriteExt}; 11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tracing::{debug, error, info, warn}; 12use tracing::{debug, error, info, warn};
13 13
14use super::authorization::{ 14use super::authorization::{
15 get_authorization_for_owner, parse_pushed_refs, validate_push_refs, AuthorizationResult, 15 get_authorization_for_owner, parse_pushed_refs, validate_nostr_ref_pushes, validate_push_refs,
16 AuthorizationResult,
16}; 17};
17use super::protocol::{GitService, PktLine}; 18use super::protocol::{GitService, PktLine};
18use super::subprocess::GitSubprocess; 19use super::subprocess::GitSubprocess;
@@ -27,7 +28,10 @@ pub async fn handle_info_refs(
27 repo_path: PathBuf, 28 repo_path: PathBuf,
28 service: GitService, 29 service: GitService,
29) -> Result<Response<Full<Bytes>>, GitError> { 30) -> Result<Response<Full<Bytes>>, GitError> {
30 debug!("Handling info/refs for {:?} with service {:?}", repo_path, service); 31 debug!(
32 "Handling info/refs for {:?} with service {:?}",
33 repo_path, service
34 );
31 35
32 // Check if repository exists 36 // Check if repository exists
33 if !repo_path.exists() { 37 if !repo_path.exists() {
@@ -36,55 +40,54 @@ pub async fn handle_info_refs(
36 } 40 }
37 41
38 // Spawn git with --advertise-refs 42 // Spawn git with --advertise-refs
39 let mut git = GitSubprocess::spawn(service, &repo_path, true) 43 let mut git = GitSubprocess::spawn(service, &repo_path, true).map_err(|e| {
40 .map_err(|e| { 44 error!("Failed to spawn git process: {}", e);
41 error!("Failed to spawn git process: {}", e); 45 GitError::ProcessSpawnFailed(e)
42 GitError::ProcessSpawnFailed(e) 46 })?;
43 })?;
44 47
45 // Read the output from git 48 // Read the output from git
46 let mut output = Vec::new(); 49 let mut output = Vec::new();
47 let mut stderr_output = Vec::new(); 50 let mut stderr_output = Vec::new();
48 51
49 if let Some(stdout) = git.take_stdout() { 52 if let Some(stdout) = git.take_stdout() {
50 let mut stdout = stdout; 53 let mut stdout = stdout;
51 stdout.read_to_end(&mut output).await 54 stdout.read_to_end(&mut output).await.map_err(|e| {
52 .map_err(|e| { 55 error!("Failed to read git output: {}", e);
53 error!("Failed to read git output: {}", e); 56 GitError::IoError(e)
54 GitError::IoError(e) 57 })?;
55 })?;
56 } 58 }
57 59
58 if let Some(stderr) = git.take_stderr() { 60 if let Some(stderr) = git.take_stderr() {
59 let mut stderr = stderr; 61 let mut stderr = stderr;
60 stderr.read_to_end(&mut stderr_output).await 62 stderr.read_to_end(&mut stderr_output).await.map_err(|e| {
61 .map_err(|e| { 63 error!("Failed to read git stderr: {}", e);
62 error!("Failed to read git stderr: {}", e); 64 GitError::IoError(e)
63 GitError::IoError(e) 65 })?;
64 })?;
65 } 66 }
66 67
67 // Wait for process to complete 68 // Wait for process to complete
68 let status = git.wait().await 69 let status = git.wait().await.map_err(|e| {
69 .map_err(|e| { 70 error!("Failed to wait for git process: {}", e);
70 error!("Failed to wait for git process: {}", e); 71 GitError::IoError(e)
71 GitError::IoError(e) 72 })?;
72 })?;
73 73
74 if !status.success() { 74 if !status.success() {
75 let stderr_str = String::from_utf8_lossy(&stderr_output); 75 let stderr_str = String::from_utf8_lossy(&stderr_output);
76 error!("Git process failed with status: {:?}, stderr: {}", status, stderr_str); 76 error!(
77 "Git process failed with status: {:?}, stderr: {}",
78 status, stderr_str
79 );
77 return Err(GitError::GitFailed(status.code())); 80 return Err(GitError::GitFailed(status.code()));
78 } 81 }
79 82
80 // Build response with pkt-line header 83 // Build response with pkt-line header
81 let mut response_body = Vec::new(); 84 let mut response_body = Vec::new();
82 85
83 // First line: service advertisement 86 // First line: service advertisement
84 let service_line = format!("# service={}\n", service.as_str()); 87 let service_line = format!("# service={}\n", service.as_str());
85 response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode()); 88 response_body.extend_from_slice(&PktLine::data(service_line.as_bytes()).encode());
86 response_body.extend_from_slice(&PktLine::flush().encode()); 89 response_body.extend_from_slice(&PktLine::flush().encode());
87 90
88 // Then the git output 91 // Then the git output
89 response_body.extend_from_slice(&output); 92 response_body.extend_from_slice(&output);
90 93
@@ -113,7 +116,9 @@ pub async fn handle_upload_pack(
113 116
114 // Write request to git's stdin 117 // Write request to git's stdin
115 if let Some(mut stdin) = git.take_stdin() { 118 if let Some(mut stdin) = git.take_stdin() {
116 stdin.write_all(&request_body).await 119 stdin
120 .write_all(&request_body)
121 .await
117 .map_err(GitError::IoError)?; 122 .map_err(GitError::IoError)?;
118 // Close stdin to signal end of input 123 // Close stdin to signal end of input
119 drop(stdin); 124 drop(stdin);
@@ -122,22 +127,25 @@ pub async fn handle_upload_pack(
122 // Read response from git's stdout 127 // Read response from git's stdout
123 let mut output = Vec::new(); 128 let mut output = Vec::new();
124 let mut stderr_output = Vec::new(); 129 let mut stderr_output = Vec::new();
125 130
126 if let Some(stdout) = git.take_stdout() { 131 if let Some(stdout) = git.take_stdout() {
127 let mut stdout = stdout; 132 let mut stdout = stdout;
128 stdout.read_to_end(&mut output).await 133 stdout
134 .read_to_end(&mut output)
135 .await
129 .map_err(GitError::IoError)?; 136 .map_err(GitError::IoError)?;
130 } 137 }
131 138
132 if let Some(stderr) = git.take_stderr() { 139 if let Some(stderr) = git.take_stderr() {
133 let mut stderr = stderr; 140 let mut stderr = stderr;
134 stderr.read_to_end(&mut stderr_output).await 141 stderr
142 .read_to_end(&mut stderr_output)
143 .await
135 .map_err(GitError::IoError)?; 144 .map_err(GitError::IoError)?;
136 } 145 }
137 146
138 // Wait for process 147 // Wait for process
139 let status = git.wait().await 148 let status = git.wait().await.map_err(GitError::IoError)?;
140 .map_err(GitError::IoError)?;
141 149
142 if !status.success() { 150 if !status.success() {
143 let stderr_str = String::from_utf8_lossy(&stderr_output); 151 let stderr_str = String::from_utf8_lossy(&stderr_output);
@@ -194,10 +202,7 @@ pub async fn handle_receive_pack(
194 match authorize_push(db, identifier, owner_pubkey, &request_body).await { 202 match authorize_push(db, identifier, owner_pubkey, &request_body).await {
195 Ok(auth_result) => { 203 Ok(auth_result) => {
196 if !auth_result.authorized { 204 if !auth_result.authorized {
197 warn!( 205 warn!("Push rejected for {}: {}", identifier, auth_result.reason);
198 "Push rejected for {}: {}",
199 identifier, auth_result.reason
200 );
201 return Err(GitError::Unauthorized); 206 return Err(GitError::Unauthorized);
202 } 207 }
203 info!( 208 info!(
@@ -209,10 +214,7 @@ pub async fn handle_receive_pack(
209 authorized_state = auth_result.state; 214 authorized_state = auth_result.state;
210 } 215 }
211 Err(e) => { 216 Err(e) => {
212 warn!( 217 warn!("Authorization check failed for {}: {}", identifier, e);
213 "Authorization check failed for {}: {}",
214 identifier, e
215 );
216 return Err(GitError::Unauthorized); 218 return Err(GitError::Unauthorized);
217 } 219 }
218 } 220 }
@@ -226,7 +228,9 @@ pub async fn handle_receive_pack(
226 228
227 // Write request to git's stdin 229 // Write request to git's stdin
228 if let Some(mut stdin) = git.take_stdin() { 230 if let Some(mut stdin) = git.take_stdin() {
229 stdin.write_all(&request_body).await 231 stdin
232 .write_all(&request_body)
233 .await
230 .map_err(GitError::IoError)?; 234 .map_err(GitError::IoError)?;
231 drop(stdin); 235 drop(stdin);
232 } 236 }
@@ -234,22 +238,25 @@ pub async fn handle_receive_pack(
234 // Read response from git's stdout 238 // Read response from git's stdout
235 let mut output = Vec::new(); 239 let mut output = Vec::new();
236 let mut stderr_output = Vec::new(); 240 let mut stderr_output = Vec::new();
237 241
238 if let Some(stdout) = git.take_stdout() { 242 if let Some(stdout) = git.take_stdout() {
239 let mut stdout = stdout; 243 let mut stdout = stdout;
240 stdout.read_to_end(&mut output).await 244 stdout
245 .read_to_end(&mut output)
246 .await
241 .map_err(GitError::IoError)?; 247 .map_err(GitError::IoError)?;
242 } 248 }
243 249
244 if let Some(stderr) = git.take_stderr() { 250 if let Some(stderr) = git.take_stderr() {
245 let mut stderr = stderr; 251 let mut stderr = stderr;
246 stderr.read_to_end(&mut stderr_output).await 252 stderr
253 .read_to_end(&mut stderr_output)
254 .await
247 .map_err(GitError::IoError)?; 255 .map_err(GitError::IoError)?;
248 } 256 }
249 257
250 // Wait for process 258 // Wait for process
251 let status = git.wait().await 259 let status = git.wait().await.map_err(GitError::IoError)?;
252 .map_err(GitError::IoError)?;
253 260
254 if !status.success() { 261 if !status.success() {
255 let stderr_str = String::from_utf8_lossy(&stderr_output); 262 let stderr_str = String::from_utf8_lossy(&stderr_output);
@@ -266,10 +273,7 @@ pub async fn handle_receive_pack(
266 if let Some(commit) = state.get_branch_commit(branch_name) { 273 if let Some(commit) = state.get_branch_commit(branch_name) {
267 match try_set_head_if_available(&repo_path, head_ref, commit) { 274 match try_set_head_if_available(&repo_path, head_ref, commit) {
268 Ok(true) => { 275 Ok(true) => {
269 info!( 276 info!("Set HEAD to {} after push to {:?}", head_ref, repo_path);
270 "Set HEAD to {} after push to {:?}",
271 head_ref, repo_path
272 );
273 } 277 }
274 Ok(false) => { 278 Ok(false) => {
275 debug!( 279 debug!(
@@ -278,10 +282,7 @@ pub async fn handle_receive_pack(
278 ); 282 );
279 } 283 }
280 Err(e) => { 284 Err(e) => {
281 warn!( 285 warn!("Failed to set HEAD after push: {}", e);
282 "Failed to set HEAD after push: {}",
283 e
284 );
285 } 286 }
286 } 287 }
287 } 288 }
@@ -291,7 +292,10 @@ pub async fn handle_receive_pack(
291 292
292 Ok(Response::builder() 293 Ok(Response::builder()
293 .status(StatusCode::OK) 294 .status(StatusCode::OK)
294 .header("content-type", GitService::ReceivePack.result_content_type()) 295 .header(
296 "content-type",
297 GitService::ReceivePack.result_content_type(),
298 )
295 .header("cache-control", "no-cache") 299 .header("cache-control", "no-cache")
296 .body(Full::new(Bytes::from(output))) 300 .body(Full::new(Bytes::from(output)))
297 .unwrap()) 301 .unwrap())
@@ -305,6 +309,7 @@ pub async fn handle_receive_pack(
305/// 3. Collects authorized publishers from that announcement (owner + maintainers) 309/// 3. Collects authorized publishers from that announcement (owner + maintainers)
306/// 4. Gets the latest authorized state from those publishers 310/// 4. Gets the latest authorized state from those publishers
307/// 5. Validates that pushed refs match the state 311/// 5. Validates that pushed refs match the state
312/// 6. Validates refs/nostr/<event-id> has valid event id and if event exists, `c` tag matches ref
308async fn authorize_push( 313async fn authorize_push(
309 database: &Arc<MemoryDatabase>, 314 database: &Arc<MemoryDatabase>,
310 identifier: &str, 315 identifier: &str,
@@ -323,59 +328,79 @@ async fn authorize_push(
323 debug!(" {} {} -> {}", ref_name, old_oid, new_oid); 328 debug!(" {} {} -> {}", ref_name, old_oid, new_oid);
324 } 329 }
325 330
326 // Check if ALL pushed refs are to refs/nostr/ with valid EventId format 331 // Separate refs/nostr/ refs from other refs
327 // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`" 332 // Per GRASP-01: "MUST accept pushes via this service to `refs/nostr/<event-id>`"
328 // These pushes only require EventId format validation, not state validation 333 let (nostr_refs, other_refs): (Vec<_>, Vec<_>) = pushed_refs
329 let all_refs_nostr_valid = !pushed_refs.is_empty() 334 .iter()
330 && pushed_refs.iter().all(|(_, _, ref_name)| { 335 .partition(|(_, _, ref_name)| ref_name.starts_with("refs/nostr/"));
331 if let Some(event_id_str) = ref_name.strip_prefix("refs/nostr/") { 336
332 // Validate it parses as a valid EventId 337 // Validate refs/nostr/ refs if any exist
333 EventId::parse(event_id_str).is_ok() 338 if !nostr_refs.is_empty() {
334 } else { 339 debug!(
335 false 340 "Found {} refs/nostr/ refs - validating against events",
336 } 341 nostr_refs.len()
337 }); 342 );
338 343
339 if all_refs_nostr_valid { 344 // Validate refs/nostr/ pushes: checks event ID format and commit matching
340 debug!("All refs are refs/nostr/ with valid EventId format - authorized without state check"); 345 let nostr_refs_owned: Vec<(String, String, String)> = nostr_refs
341 // Return success for refs/nostr/ pushes without requiring state 346 .into_iter()
347 .map(|(a, b, c)| (a.clone(), b.clone(), c.clone()))
348 .collect();
349 if let Err(e) = validate_nostr_ref_pushes(database, &nostr_refs_owned).await {
350 warn!("refs/nostr/ validation failed: {}", e);
351 return Ok(AuthorizationResult::denied(format!(
352 "refs/nostr/ validation failed: {}",
353 e
354 )));
355 }
356 debug!("refs/nostr/ push validated successfully");
357 }
358
359 // If only refs/nostr/ refs, we're done - return success
360 if other_refs.is_empty() {
361 debug!("Only refs/nostr/ refs in push - authorization complete");
342 return Ok(AuthorizationResult { 362 return Ok(AuthorizationResult {
343 authorized: true, 363 authorized: true,
344 reason: "Push to refs/nostr/ with valid EventId format".to_string(), 364 reason: "Push to refs/nostr/ validated against events".to_string(),
345 state: None, 365 state: None,
346 maintainers: vec![], 366 maintainers: vec![],
347 }); 367 });
348 } 368 }
349 369
350 // For non-refs/nostr/ pushes, require state validation as normal 370 // For non-refs/nostr/ refs, require state validation
351 debug!("Non-refs/nostr/ push detected - checking state authorization"); 371 debug!(
372 "Found {} non-refs/nostr/ refs - checking state authorization",
373 other_refs.len()
374 );
352 let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?; 375 let auth_result = get_authorization_for_owner(database, identifier, owner_pubkey).await?;
353 376
354 if !auth_result.authorized { 377 if !auth_result.authorized {
355 return Ok(auth_result); 378 return Ok(auth_result);
356 } 379 }
357 380
358 // Parse refs from the push request 381 // Convert other_refs for validation
359 let pushed_refs = parse_pushed_refs(request_body); 382 let other_refs_owned: Vec<(String, String, String)> = other_refs
360 debug!("Parsed {} refs from push request", pushed_refs.len()); 383 .into_iter()
361 for (old_oid, new_oid, ref_name) in &pushed_refs { 384 .map(|(a, b, c)| (a.clone(), b.clone(), c.clone()))
362 debug!(" {} {} -> {}", ref_name, old_oid, new_oid); 385 .collect();
363 }
364 386
365 // Validate refs against state 387 // Validate non-refs/nostr/ refs against state
366 if let Some(ref state) = auth_result.state { 388 if let Some(ref state) = auth_result.state {
367 debug!("Validating against state with {} branches", state.branches.len()); 389 debug!(
368 390 "Validating against state with {} branches",
391 state.branches.len()
392 );
393
369 // If we have a state event but couldn't parse any refs, reject the push. 394 // If we have a state event but couldn't parse any refs, reject the push.
370 // This protects against parsing failures allowing unauthorized pushes. 395 // This protects against parsing failures allowing unauthorized pushes.
371 if pushed_refs.is_empty() && !state.branches.is_empty() { 396 if other_refs_owned.is_empty() && !state.branches.is_empty() {
372 warn!("No refs parsed from push request but state event has branches - rejecting"); 397 warn!("No refs parsed from push request but state event has branches - rejecting");
373 return Ok(AuthorizationResult::denied( 398 return Ok(AuthorizationResult::denied(
374 "Failed to parse refs from push request - cannot validate against state" 399 "Failed to parse refs from push request - cannot validate against state",
375 )); 400 ));
376 } 401 }
377 402
378 if let Err(e) = validate_push_refs(state, &pushed_refs) { 403 if let Err(e) = validate_push_refs(state, &other_refs_owned) {
379 warn!("Ref validation failed: {}", e); 404 warn!("Ref validation failed: {}", e);
380 return Ok(AuthorizationResult::denied(format!( 405 return Ok(AuthorizationResult::denied(format!(
381 "Ref validation failed: {}", 406 "Ref validation failed: {}",
@@ -423,4 +448,4 @@ impl GitError {
423 _ => StatusCode::INTERNAL_SERVER_ERROR, 448 _ => StatusCode::INTERNAL_SERVER_ERROR,
424 } 449 }
425 } 450 }
426} \ No newline at end of file 451}
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 076e211..494f8b9 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -40,7 +40,7 @@ use tracing::{debug, info};
40pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { 40pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf {
41 // Remove .git suffix if present 41 // Remove .git suffix if present
42 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier); 42 let identifier = identifier.strip_suffix(".git").unwrap_or(identifier);
43 43
44 PathBuf::from(git_data_path) 44 PathBuf::from(git_data_path)
45 .join(npub) 45 .join(npub)
46 .join(format!("{}.git", identifier)) 46 .join(format!("{}.git", identifier))
@@ -89,7 +89,10 @@ pub fn commit_exists(repo_path: &Path, commit_hash: &str) -> bool {
89pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { 89pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> {
90 // Validate the ref format 90 // Validate the ref format
91 if !head_ref.starts_with("refs/heads/") { 91 if !head_ref.starts_with("refs/heads/") {
92 return Err(format!("Invalid HEAD ref: {} (must start with refs/heads/)", head_ref)); 92 return Err(format!(
93 "Invalid HEAD ref: {} (must start with refs/heads/)",
94 head_ref
95 ));
93 } 96 }
94 97
95 debug!("Setting HEAD to {} in {}", head_ref, repo_path.display()); 98 debug!("Setting HEAD to {} in {}", head_ref, repo_path.display());
@@ -130,7 +133,10 @@ pub fn try_set_head_if_available(
130) -> Result<bool, String> { 133) -> Result<bool, String> {
131 // Check if repository exists 134 // Check if repository exists
132 if !repo_path.exists() { 135 if !repo_path.exists() {
133 debug!("Repository not found at {}, cannot set HEAD", repo_path.display()); 136 debug!(
137 "Repository not found at {}, cannot set HEAD",
138 repo_path.display()
139 );
134 return Ok(false); 140 return Ok(false);
135 } 141 }
136 142
@@ -149,6 +155,115 @@ pub fn try_set_head_if_available(
149 Ok(true) 155 Ok(true)
150} 156}
151 157
158/// Get the commit hash that a ref points to
159///
160/// # Arguments
161/// * `repo_path` - Path to the bare git repository
162/// * `ref_name` - The ref name (e.g., "refs/nostr/<event-id>")
163///
164/// # Returns
165/// Some(commit_hash) if the ref exists, None otherwise
166pub fn get_ref_commit(repo_path: &Path, ref_name: &str) -> Option<String> {
167 let output = Command::new("git")
168 .args(["rev-parse", ref_name])
169 .current_dir(repo_path)
170 .output()
171 .ok()?;
172
173 if output.status.success() {
174 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
175 } else {
176 None
177 }
178}
179
180/// Delete a git ref from the repository
181///
182/// # Arguments
183/// * `repo_path` - Path to the bare git repository
184/// * `ref_name` - The ref name to delete (e.g., "refs/nostr/<event-id>")
185///
186/// # Returns
187/// Ok(()) if successful, Err with error message otherwise
188pub fn delete_ref(repo_path: &Path, ref_name: &str) -> Result<(), String> {
189 debug!("Deleting ref {} from {}", ref_name, repo_path.display());
190
191 let output = Command::new("git")
192 .args(["update-ref", "-d", ref_name])
193 .current_dir(repo_path)
194 .output()
195 .map_err(|e| format!("Failed to execute git update-ref: {}", e))?;
196
197 if !output.status.success() {
198 let stderr = String::from_utf8_lossy(&output.stderr);
199 return Err(format!("git update-ref -d failed: {}", stderr));
200 }
201
202 info!("Deleted ref {} from {}", ref_name, repo_path.display());
203 Ok(())
204}
205
206/// Validate refs/nostr/<event-id> ref against expected commit
207///
208/// If the ref exists but points to a different commit than expected,
209/// the ref is deleted. This is called when a PR event is received to
210/// ensure refs/nostr refs are consistent with their corresponding events.
211///
212/// # Arguments
213/// * `repo_path` - Path to the bare git repository
214/// * `event_id` - The event ID (hex string)
215/// * `expected_commit` - The commit hash from the event's `c` tag
216///
217/// # Returns
218/// Ok(true) if ref was deleted (mismatch), Ok(false) if no action taken, Err on failure
219pub fn validate_nostr_ref(
220 repo_path: &Path,
221 event_id: &str,
222 expected_commit: &str,
223) -> Result<bool, String> {
224 let ref_name = format!("refs/nostr/{}", event_id);
225
226 // Check if repository exists
227 if !repo_path.exists() {
228 debug!(
229 "Repository not found at {}, skipping ref validation",
230 repo_path.display()
231 );
232 return Ok(false);
233 }
234
235 // Check if the ref exists
236 let current_commit = match get_ref_commit(repo_path, &ref_name) {
237 Some(commit) => commit,
238 None => {
239 debug!("Ref {} does not exist in {}", ref_name, repo_path.display());
240 return Ok(false);
241 }
242 };
243
244 // Compare commits
245 if current_commit == expected_commit {
246 debug!(
247 "Ref {} points to correct commit {} in {}",
248 ref_name,
249 expected_commit,
250 repo_path.display()
251 );
252 return Ok(false);
253 }
254
255 // Commit mismatch - delete the ref
256 info!(
257 "Deleting mismatched ref {} in {}: expected {}, found {}",
258 ref_name,
259 repo_path.display(),
260 expected_commit,
261 current_commit
262 );
263 delete_ref(repo_path, &ref_name)?;
264 Ok(true)
265}
266
152/// Get the current HEAD ref from a repository 267/// Get the current HEAD ref from a repository
153/// 268///
154/// # Arguments 269/// # Arguments
@@ -178,25 +293,25 @@ pub fn get_repository_head(repo_path: &Path) -> Option<String> {
178pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { 293pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> {
179 // Remove leading slash 294 // Remove leading slash
180 let path = path.strip_prefix('/').unwrap_or(path); 295 let path = path.strip_prefix('/').unwrap_or(path);
181 296
182 // Split into components 297 // Split into components
183 let parts: Vec<&str> = path.splitn(3, '/').collect(); 298 let parts: Vec<&str> = path.splitn(3, '/').collect();
184 299
185 if parts.len() < 3 { 300 if parts.len() < 3 {
186 return None; 301 return None;
187 } 302 }
188 303
189 let npub = parts[0]; 304 let npub = parts[0];
190 let repo_part = parts[1]; 305 let repo_part = parts[1];
191 let subpath = parts[2]; 306 let subpath = parts[2];
192 307
193 // Extract identifier (remove .git suffix if present for the middle part) 308 // Extract identifier (remove .git suffix if present for the middle part)
194 let identifier = if repo_part.ends_with(".git") { 309 let identifier = if repo_part.ends_with(".git") {
195 &repo_part[..repo_part.len() - 4] 310 &repo_part[..repo_part.len() - 4]
196 } else { 311 } else {
197 repo_part 312 repo_part
198 }; 313 };
199 314
200 Some((npub, identifier, subpath)) 315 Some((npub, identifier, subpath))
201} 316}
202 317
@@ -210,13 +325,13 @@ mod tests {
210 fn create_test_repo() -> (TempDir, PathBuf) { 325 fn create_test_repo() -> (TempDir, PathBuf) {
211 let temp_dir = TempDir::new().unwrap(); 326 let temp_dir = TempDir::new().unwrap();
212 let repo_path = temp_dir.path().join("test.git"); 327 let repo_path = temp_dir.path().join("test.git");
213 328
214 // Initialize bare repository 329 // Initialize bare repository
215 Command::new("git") 330 Command::new("git")
216 .args(["init", "--bare", repo_path.to_str().unwrap()]) 331 .args(["init", "--bare", repo_path.to_str().unwrap()])
217 .output() 332 .output()
218 .unwrap(); 333 .unwrap();
219 334
220 (temp_dir, repo_path) 335 (temp_dir, repo_path)
221 } 336 }
222 337
@@ -225,19 +340,23 @@ mod tests {
225 let temp_dir = TempDir::new().unwrap(); 340 let temp_dir = TempDir::new().unwrap();
226 let work_dir = temp_dir.path().join("work"); 341 let work_dir = temp_dir.path().join("work");
227 let bare_repo = temp_dir.path().join("test.git"); 342 let bare_repo = temp_dir.path().join("test.git");
228 343
229 // Initialize bare repository 344 // Initialize bare repository
230 Command::new("git") 345 Command::new("git")
231 .args(["init", "--bare", bare_repo.to_str().unwrap()]) 346 .args(["init", "--bare", "--initial-branch=main", bare_repo.to_str().unwrap()])
232 .output() 347 .output()
233 .unwrap(); 348 .unwrap();
234 349
235 // Clone to working directory 350 // Clone to working directory
236 Command::new("git") 351 Command::new("git")
237 .args(["clone", bare_repo.to_str().unwrap(), work_dir.to_str().unwrap()]) 352 .args([
353 "clone",
354 bare_repo.to_str().unwrap(),
355 work_dir.to_str().unwrap(),
356 ])
238 .output() 357 .output()
239 .unwrap(); 358 .unwrap();
240 359
241 // Configure git for commits 360 // Configure git for commits
242 Command::new("git") 361 Command::new("git")
243 .args(["config", "user.email", "test@test.com"]) 362 .args(["config", "user.email", "test@test.com"])
@@ -249,7 +368,7 @@ mod tests {
249 .current_dir(&work_dir) 368 .current_dir(&work_dir)
250 .output() 369 .output()
251 .unwrap(); 370 .unwrap();
252 371
253 // Create a file and commit 372 // Create a file and commit
254 fs::write(work_dir.join("README.md"), "# Test").unwrap(); 373 fs::write(work_dir.join("README.md"), "# Test").unwrap();
255 Command::new("git") 374 Command::new("git")
@@ -262,7 +381,7 @@ mod tests {
262 .current_dir(&work_dir) 381 .current_dir(&work_dir)
263 .output() 382 .output()
264 .unwrap(); 383 .unwrap();
265 384
266 // Get commit hash 385 // Get commit hash
267 let output = Command::new("git") 386 let output = Command::new("git")
268 .args(["rev-parse", "HEAD"]) 387 .args(["rev-parse", "HEAD"])
@@ -270,41 +389,27 @@ mod tests {
270 .output() 389 .output()
271 .unwrap(); 390 .unwrap();
272 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); 391 let commit_hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
273 392
274 // Push to bare repo 393 // Push to bare repo
275 Command::new("git") 394 Command::new("git")
276 .args(["push", "origin", "master"]) 395 .args(["push", "origin", "main"])
277 .current_dir(&work_dir) 396 .current_dir(&work_dir)
278 .output() 397 .output()
279 .unwrap(); 398 .unwrap();
280 399
281 (temp_dir, bare_repo, commit_hash) 400 (temp_dir, bare_repo, commit_hash)
282 } 401 }
283 402
284 #[test] 403 #[test]
285 fn test_resolve_repo_path() { 404 fn test_resolve_repo_path() {
286 let path = resolve_repo_path( 405 let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo");
287 "/data/git", 406 assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git"));
288 "npub1abc123",
289 "my-repo"
290 );
291 assert_eq!(
292 path,
293 PathBuf::from("/data/git/npub1abc123/my-repo.git")
294 );
295 } 407 }
296 408
297 #[test] 409 #[test]
298 fn test_resolve_repo_path_with_git_suffix() { 410 fn test_resolve_repo_path_with_git_suffix() {
299 let path = resolve_repo_path( 411 let path = resolve_repo_path("/data/git", "npub1abc123", "my-repo.git");
300 "/data/git", 412 assert_eq!(path, PathBuf::from("/data/git/npub1abc123/my-repo.git"));
301 "npub1abc123",
302 "my-repo.git"
303 );
304 assert_eq!(
305 path,
306 PathBuf::from("/data/git/npub1abc123/my-repo.git")
307 );
308 } 413 }
309 414
310 #[test] 415 #[test]
@@ -332,7 +437,10 @@ mod tests {
332 #[test] 437 #[test]
333 fn test_commit_exists_nonexistent() { 438 fn test_commit_exists_nonexistent() {
334 let (_temp_dir, repo_path) = create_test_repo(); 439 let (_temp_dir, repo_path) = create_test_repo();
335 assert!(!commit_exists(&repo_path, "deadbeef1234567890abcdef1234567890abcdef")); 440 assert!(!commit_exists(
441 &repo_path,
442 "deadbeef1234567890abcdef1234567890abcdef"
443 ));
336 } 444 }
337 445
338 #[test] 446 #[test]
@@ -344,11 +452,11 @@ mod tests {
344 #[test] 452 #[test]
345 fn test_set_repository_head() { 453 fn test_set_repository_head() {
346 let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit(); 454 let (_temp_dir, repo_path, _commit_hash) = create_test_repo_with_commit();
347 455
348 // Default HEAD might be refs/heads/master 456 // Default HEAD might be refs/heads/master
349 let result = set_repository_head(&repo_path, "refs/heads/main"); 457 let result = set_repository_head(&repo_path, "refs/heads/main");
350 assert!(result.is_ok()); 458 assert!(result.is_ok());
351 459
352 let head = get_repository_head(&repo_path); 460 let head = get_repository_head(&repo_path);
353 assert_eq!(head, Some("refs/heads/main".to_string())); 461 assert_eq!(head, Some("refs/heads/main".to_string()));
354 } 462 }
@@ -356,7 +464,7 @@ mod tests {
356 #[test] 464 #[test]
357 fn test_set_repository_head_invalid_ref() { 465 fn test_set_repository_head_invalid_ref() {
358 let (_temp_dir, repo_path) = create_test_repo(); 466 let (_temp_dir, repo_path) = create_test_repo();
359 467
360 // Invalid ref format should fail 468 // Invalid ref format should fail
361 let result = set_repository_head(&repo_path, "main"); 469 let result = set_repository_head(&repo_path, "main");
362 assert!(result.is_err()); 470 assert!(result.is_err());
@@ -366,13 +474,13 @@ mod tests {
366 #[test] 474 #[test]
367 fn test_try_set_head_if_available_commit_missing() { 475 fn test_try_set_head_if_available_commit_missing() {
368 let (_temp_dir, repo_path) = create_test_repo(); 476 let (_temp_dir, repo_path) = create_test_repo();
369 477
370 let result = try_set_head_if_available( 478 let result = try_set_head_if_available(
371 &repo_path, 479 &repo_path,
372 "refs/heads/main", 480 "refs/heads/main",
373 "deadbeef1234567890abcdef1234567890abcdef", 481 "deadbeef1234567890abcdef1234567890abcdef",
374 ); 482 );
375 483
376 // Should return Ok(false) - commit not found 484 // Should return Ok(false) - commit not found
377 assert!(result.is_ok()); 485 assert!(result.is_ok());
378 assert!(!result.unwrap()); 486 assert!(!result.unwrap());
@@ -381,19 +489,15 @@ mod tests {
381 #[test] 489 #[test]
382 fn test_try_set_head_if_available_success() { 490 fn test_try_set_head_if_available_success() {
383 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit(); 491 let (_temp_dir, repo_path, commit_hash) = create_test_repo_with_commit();
384 492
385 let result = try_set_head_if_available( 493 let result = try_set_head_if_available(&repo_path, "refs/heads/main", &commit_hash);
386 &repo_path, 494
387 "refs/heads/main",
388 &commit_hash,
389 );
390
391 // Should return Ok(true) - HEAD was set 495 // Should return Ok(true) - HEAD was set
392 assert!(result.is_ok()); 496 assert!(result.is_ok());
393 assert!(result.unwrap()); 497 assert!(result.unwrap());
394 498
395 // Verify HEAD was set 499 // Verify HEAD was set
396 let head = get_repository_head(&repo_path); 500 let head = get_repository_head(&repo_path);
397 assert_eq!(head, Some("refs/heads/main".to_string())); 501 assert_eq!(head, Some("refs/heads/main".to_string()));
398 } 502 }
399} \ No newline at end of file 503}