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>2025-12-01 11:56:49 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-01 11:58:34 +0000
commit7a78815e29b01c83f3d0ec195ba717a2eba8cd37 (patch)
tree4c5ccd9b812f1d1d75ed218501192ddc5459fd12 /src
parente6ceab90de1acad154624022a6036efac18abab6 (diff)
reject push when refs/nostr/<event-id> doesnt match known event and delete incorrect ref on event receive
Diffstat (limited to 'src')
-rw-r--r--src/git/authorization.rs142
-rw-r--r--src/git/handlers.rs211
-rw-r--r--src/git/mod.rs210
-rw-r--r--src/nostr/builder.rs389
-rw-r--r--src/nostr/events.rs6
5 files changed, 742 insertions, 216 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}
diff --git a/src/nostr/builder.rs b/src/nostr/builder.rs
index 7aa2b97..8e9926a 100644
--- a/src/nostr/builder.rs
+++ b/src/nostr/builder.rs
@@ -14,8 +14,8 @@ use nostr_relay_builder::prelude::*;
14use crate::config::{Config, DatabaseBackend}; 14use crate::config::{Config, DatabaseBackend};
15use crate::git; 15use crate::git;
16use crate::nostr::events::{ 16use crate::nostr::events::{
17 validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, 17 validate_announcement, validate_state, RepositoryAnnouncement, RepositoryState, KIND_PR,
18 KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE, 18 KIND_PR_UPDATE, KIND_REPOSITORY_ANNOUNCEMENT, KIND_REPOSITORY_STATE,
19}; 19};
20 20
21/// NIP-34 Write Policy with Full GRASP-01 Event Validation 21/// NIP-34 Write Policy with Full GRASP-01 Event Validation
@@ -36,7 +36,11 @@ pub struct Nip34WritePolicy {
36} 36}
37 37
38impl Nip34WritePolicy { 38impl Nip34WritePolicy {
39 pub fn new(domain: impl Into<String>, database: Arc<MemoryDatabase>, git_data_path: impl Into<PathBuf>) -> Self { 39 pub fn new(
40 domain: impl Into<String>,
41 database: Arc<MemoryDatabase>,
42 git_data_path: impl Into<PathBuf>,
43 ) -> Self {
40 Self { 44 Self {
41 domain: domain.into(), 45 domain: domain.into(),
42 database, 46 database,
@@ -48,7 +52,7 @@ impl Nip34WritePolicy {
48 /// Path format: <git_data_path>/<npub>/<identifier>.git 52 /// Path format: <git_data_path>/<npub>/<identifier>.git
49 fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> { 53 fn ensure_bare_repository(&self, announcement: &RepositoryAnnouncement) -> Result<(), String> {
50 let repo_path = self.git_data_path.join(&announcement.repo_path()); 54 let repo_path = self.git_data_path.join(&announcement.repo_path());
51 55
52 // Check if repository already exists 56 // Check if repository already exists
53 if repo_path.exists() { 57 if repo_path.exists() {
54 tracing::debug!("Repository already exists at {}", repo_path.display()); 58 tracing::debug!("Repository already exists at {}", repo_path.display());
@@ -56,13 +60,12 @@ impl Nip34WritePolicy {
56 } 60 }
57 61
58 // Create parent directory (npub directory) 62 // Create parent directory (npub directory)
59 let parent = repo_path.parent().ok_or_else(|| { 63 let parent = repo_path
60 format!("Invalid repository path: {}", repo_path.display()) 64 .parent()
61 })?; 65 .ok_or_else(|| format!("Invalid repository path: {}", repo_path.display()))?;
62 66
63 std::fs::create_dir_all(parent).map_err(|e| { 67 std::fs::create_dir_all(parent)
64 format!("Failed to create directory {}: {}", parent.display(), e) 68 .map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
65 })?;
66 69
67 // Initialize bare repository using git command 70 // Initialize bare repository using git command
68 let output = std::process::Command::new("git") 71 let output = std::process::Command::new("git")
@@ -165,7 +168,11 @@ impl Nip34WritePolicy {
165 tracing::debug!( 168 tracing::debug!(
166 "Found authorized announcement for {}: owner={}, maintainer={}", 169 "Found authorized announcement for {}: owner={}, maintainer={}",
167 identifier, 170 identifier,
168 if is_owner { event.pubkey.to_hex() } else { "n/a".to_string() }, 171 if is_owner {
172 event.pubkey.to_hex()
173 } else {
174 "n/a".to_string()
175 },
169 is_maintainer 176 is_maintainer
170 ); 177 );
171 authorized.push(announcement); 178 authorized.push(announcement);
@@ -198,10 +205,7 @@ impl Nip34WritePolicy {
198 let head_ref = match &state.head { 205 let head_ref = match &state.head {
199 Some(h) => h, 206 Some(h) => h,
200 None => { 207 None => {
201 tracing::debug!( 208 tracing::debug!("State event for {} has no HEAD reference", state.identifier);
202 "State event for {} has no HEAD reference",
203 state.identifier
204 );
205 return Ok(0); 209 return Ok(0);
206 } 210 }
207 }; 211 };
@@ -232,11 +236,9 @@ impl Nip34WritePolicy {
232 }; 236 };
233 237
234 // Find all announcements where state author is authorized 238 // Find all announcements where state author is authorized
235 let announcements = Self::find_authorized_announcements( 239 let announcements =
236 database, 240 Self::find_authorized_announcements(database, &state.identifier, &state.event.pubkey)
237 &state.identifier, 241 .await?;
238 &state.event.pubkey,
239 ).await?;
240 242
241 if announcements.is_empty() { 243 if announcements.is_empty() {
242 tracing::debug!( 244 tracing::debug!(
@@ -271,7 +273,7 @@ impl Nip34WritePolicy {
271 } 273 }
272 274
273 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git 275 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
274 let repo_path = self.git_data_path.join(&announcement.repo_path()); 276 let repo_path = self.git_data_path.join(announcement.repo_path().clone());
275 277
276 match git::try_set_head_if_available(&repo_path, head_ref, head_commit) { 278 match git::try_set_head_if_available(&repo_path, head_ref, head_commit) {
277 Ok(true) => { 279 Ok(true) => {
@@ -291,11 +293,7 @@ impl Nip34WritePolicy {
291 ); 293 );
292 } 294 }
293 Err(e) => { 295 Err(e) => {
294 tracing::warn!( 296 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
295 "Failed to set HEAD in {}: {}",
296 repo_path.display(),
297 e
298 );
299 } 297 }
300 } 298 }
301 } 299 }
@@ -338,6 +336,191 @@ impl Nip34WritePolicy {
338 (addressable_refs, event_refs) 336 (addressable_refs, event_refs)
339 } 337 }
340 338
339 /// Validate refs/nostr/<event-id> ref against a PR or PR Update event's `c` tag
340 ///
341 /// When a PR event (kind 1618) or PR Update event (kind 1619) is received,
342 /// this checks if a corresponding refs/nostr/<event-id> ref exists in the
343 /// repository and validates that it points to the correct commit (from the
344 /// `c` tag). If the ref exists but points to a different commit, the ref is
345 /// deleted.
346 ///
347 /// PR and PR Update events can have multiple `a` tags to update multiple
348 /// repositories simultaneously.
349 ///
350 /// This is part of GRASP-01 compliance: ensuring refs/nostr refs are consistent
351 /// with their corresponding events.
352 ///
353 /// # Arguments
354 /// * `database` - Database for looking up repository announcements
355 /// * `event` - The PR event (kind 1618) or PR Update event (kind 1619)
356 ///
357 /// # Returns
358 /// Ok(Some(n)) if n refs were deleted, Ok(None) if no action taken, Err on failure
359 async fn validate_pr_nostr_ref(
360 &self,
361 database: &Arc<MemoryDatabase>,
362 event: &Event,
363 ) -> Result<Option<usize>, String> {
364 let event_id = event.id.to_hex();
365
366 // Extract the `c` tag (commit hash) from the PR event
367 let expected_commit = event.tags.iter().find_map(|tag| {
368 let tag_vec = tag.clone().to_vec();
369 if tag_vec.len() >= 2 && tag_vec[0] == "c" {
370 Some(tag_vec[1].clone())
371 } else {
372 None
373 }
374 });
375
376 let expected_commit = match expected_commit {
377 Some(c) => c,
378 None => {
379 tracing::debug!(
380 "PR event {} has no 'c' tag, skipping ref validation",
381 event_id
382 );
383 return Ok(None);
384 }
385 };
386
387 // Extract ALL `a` tags (repository references) from the PR event
388 // PR events can reference multiple repositories
389 // Format: 30617:<pubkey>:<identifier>
390 let repo_refs: Vec<String> = event
391 .tags
392 .iter()
393 .filter_map(|tag| {
394 let tag_vec = tag.clone().to_vec();
395 if tag_vec.len() >= 2 && tag_vec[0] == "a" && tag_vec[1].starts_with("30617:") {
396 Some(tag_vec[1].clone())
397 } else {
398 None
399 }
400 })
401 .collect();
402
403 if repo_refs.is_empty() {
404 tracing::debug!(
405 "PR event {} has no repo 'a' tags, skipping ref validation",
406 event_id
407 );
408 return Ok(None);
409 }
410
411 let mut deleted_count = 0;
412
413 // Process each repository reference
414 for repo_ref in repo_refs {
415 // Parse the repo reference: 30617:<pubkey>:<identifier>
416 let parts: Vec<&str> = repo_ref.split(':').collect();
417 if parts.len() < 3 {
418 tracing::debug!(
419 "PR event {} has invalid 'a' tag format: {}",
420 event_id,
421 repo_ref
422 );
423 continue;
424 }
425
426 let repo_pubkey = match PublicKey::from_hex(parts[1]) {
427 Ok(pk) => pk,
428 Err(_) => {
429 tracing::debug!(
430 "PR event {} has invalid pubkey in 'a' tag: {}",
431 event_id,
432 parts[1]
433 );
434 continue;
435 }
436 };
437 let identifier = parts[2];
438
439 // Look up repository announcement to get the npub for path
440 let filter = Filter::new()
441 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
442 .author(repo_pubkey)
443 .custom_tag(
444 SingleLetterTag::lowercase(Alphabet::D),
445 identifier.to_string(),
446 );
447
448 let announcements: Vec<Event> = match database.query(filter).await {
449 Ok(events) => events.into_iter().collect(),
450 Err(e) => {
451 tracing::warn!(
452 "Failed to query for repository announcement for PR {}: {}",
453 event_id,
454 e
455 );
456 continue;
457 }
458 };
459
460 if announcements.is_empty() {
461 tracing::debug!(
462 "No repository announcement found for PR event {} (repo {}:{})",
463 event_id,
464 repo_pubkey.to_hex(),
465 identifier
466 );
467 continue;
468 }
469
470 // Process each matching announcement (there could be multiple)
471 for announcement_event in announcements {
472 let announcement = match RepositoryAnnouncement::from_event(announcement_event) {
473 Ok(a) => a,
474 Err(e) => {
475 tracing::warn!(
476 "Failed to parse announcement for PR {} validation: {}",
477 event_id,
478 e
479 );
480 continue;
481 }
482 };
483
484 // Build repository path
485 let repo_path = self.git_data_path.join(&announcement.repo_path());
486
487 // Validate the ref
488 match git::validate_nostr_ref(&repo_path, &event_id, &expected_commit) {
489 Ok(true) => {
490 tracing::info!(
491 "Deleted mismatched refs/nostr/{} in {} (expected commit {})",
492 event_id,
493 repo_path.display(),
494 expected_commit
495 );
496 deleted_count += 1;
497 }
498 Ok(false) => {
499 tracing::debug!(
500 "refs/nostr/{} in {} is valid or doesn't exist",
501 event_id,
502 repo_path.display()
503 );
504 }
505 Err(e) => {
506 tracing::warn!(
507 "Failed to validate refs/nostr/{} in {}: {}",
508 event_id,
509 repo_path.display(),
510 e
511 );
512 }
513 }
514 }
515 }
516
517 if deleted_count > 0 {
518 Ok(Some(deleted_count))
519 } else {
520 Ok(None)
521 }
522 }
523
341 /// Check if any addressable events (repositories) exist in database 524 /// Check if any addressable events (repositories) exist in database
342 /// Returns the first matching addressable reference found, or None if none match 525 /// Returns the first matching addressable reference found, or None if none match
343 async fn find_accepted_repository( 526 async fn find_accepted_repository(
@@ -377,16 +560,17 @@ impl Nip34WritePolicy {
377 use std::collections::HashMap; 560 use std::collections::HashMap;
378 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new(); 561 let mut by_kind: HashMap<u16, Vec<_>> = HashMap::new();
379 for (addr, kind, pubkey, identifier) in parsed_refs { 562 for (addr, kind, pubkey, identifier) in parsed_refs {
380 by_kind.entry(kind).or_default().push((addr, pubkey, identifier)); 563 by_kind
564 .entry(kind)
565 .or_default()
566 .push((addr, pubkey, identifier));
381 } 567 }
382 568
383 // Query each kind group 569 // Query each kind group
384 for (kind, refs) in by_kind { 570 for (kind, refs) in by_kind {
385 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect(); 571 let authors: Vec<PublicKey> = refs.iter().map(|(_, pk, _)| *pk).collect();
386 572
387 let filter = Filter::new() 573 let filter = Filter::new().kind(Kind::from(kind)).authors(authors);
388 .kind(Kind::from(kind))
389 .authors(authors);
390 574
391 match database.query(filter).await { 575 match database.query(filter).await {
392 Ok(events) => { 576 Ok(events) => {
@@ -445,7 +629,7 @@ impl Nip34WritePolicy {
445 event: &Event, 629 event: &Event,
446 ) -> Result<bool, String> { 630 ) -> Result<bool, String> {
447 let kind_u16 = event.kind.as_u16(); 631 let kind_u16 = event.kind.as_u16();
448 632
449 // Check if this is any kind of replaceable event 633 // Check if this is any kind of replaceable event
450 let is_regular_replaceable = kind_u16 >= 10000 && kind_u16 < 20000; 634 let is_regular_replaceable = kind_u16 >= 10000 && kind_u16 < 20000;
451 let is_parameterized_replaceable = kind_u16 >= 30000 && kind_u16 < 40000; 635 let is_parameterized_replaceable = kind_u16 >= 30000 && kind_u16 < 40000;
@@ -454,7 +638,9 @@ impl Nip34WritePolicy {
454 // Build the appropriate address format based on event type 638 // Build the appropriate address format based on event type
455 let address = if is_parameterized_replaceable { 639 let address = if is_parameterized_replaceable {
456 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons) 640 // For parameterized replaceable: kind:pubkey:d-identifier format (2 colons)
457 let identifier = event.tags.iter() 641 let identifier = event
642 .tags
643 .iter()
458 .find_map(|tag| { 644 .find_map(|tag| {
459 let tag_vec = tag.clone().to_vec(); 645 let tag_vec = tag.clone().to_vec();
460 if tag_vec.len() >= 2 && tag_vec[0] == "d" { 646 if tag_vec.len() >= 2 && tag_vec[0] == "d" {
@@ -464,12 +650,17 @@ impl Nip34WritePolicy {
464 } 650 }
465 }) 651 })
466 .unwrap_or_default(); // Empty string if no 'd' tag 652 .unwrap_or_default(); // Empty string if no 'd' tag
467 format!("{}:{}:{}", event.kind.as_u16(), event.pubkey.to_hex(), identifier) 653 format!(
654 "{}:{}:{}",
655 event.kind.as_u16(),
656 event.pubkey.to_hex(),
657 identifier
658 )
468 } else { 659 } else {
469 // For regular replaceable: kind:pubkey format (1 colon) 660 // For regular replaceable: kind:pubkey format (1 colon)
470 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex()) 661 format!("{}:{}", event.kind.as_u16(), event.pubkey.to_hex())
471 }; 662 };
472 663
473 // Check addressable reference tags: a, A, q (with address format) 664 // Check addressable reference tags: a, A, q (with address format)
474 let addressable_tags = [ 665 let addressable_tags = [
475 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference 666 SingleLetterTag::lowercase(Alphabet::A), // 'a' - addressable event reference
@@ -479,7 +670,7 @@ impl Nip34WritePolicy {
479 670
480 for tag_type in &addressable_tags { 671 for tag_type in &addressable_tags {
481 let filter = Filter::new().custom_tag(tag_type.clone(), address.clone()); 672 let filter = Filter::new().custom_tag(tag_type.clone(), address.clone());
482 673
483 match database.query(filter).await { 674 match database.query(filter).await {
484 Ok(events) => { 675 Ok(events) => {
485 if !events.is_empty() { 676 if !events.is_empty() {
@@ -492,7 +683,7 @@ impl Nip34WritePolicy {
492 } else { 683 } else {
493 // For regular events, check event ID reference tags: e, E, q (with hex ID) 684 // For regular events, check event ID reference tags: e, E, q (with hex ID)
494 let event_id_hex = event.id.to_hex(); 685 let event_id_hex = event.id.to_hex();
495 686
496 let event_id_tags = [ 687 let event_id_tags = [
497 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference 688 SingleLetterTag::lowercase(Alphabet::E), // 'e' - standard event reference
498 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference 689 SingleLetterTag::uppercase(Alphabet::E), // 'E' - NIP-22 root event reference
@@ -501,7 +692,7 @@ impl Nip34WritePolicy {
501 692
502 for tag_type in &event_id_tags { 693 for tag_type in &event_id_tags {
503 let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone()); 694 let filter = Filter::new().custom_tag(tag_type.clone(), event_id_hex.clone());
504 695
505 match database.query(filter).await { 696 match database.query(filter).await {
506 Ok(events) => { 697 Ok(events) => {
507 if !events.is_empty() { 698 if !events.is_empty() {
@@ -545,7 +736,7 @@ impl WritePolicy for Nip34WritePolicy {
545 // Note: We still accept the event even if repo creation fails 736 // Note: We still accept the event even if repo creation fails
546 // The git operation failure shouldn't prevent event acceptance 737 // The git operation failure shouldn't prevent event acceptance
547 } 738 }
548 739
549 tracing::debug!( 740 tracing::debug!(
550 "Accepted repository announcement: {}", 741 "Accepted repository announcement: {}",
551 event_id_str 742 event_id_str
@@ -563,11 +754,7 @@ impl WritePolicy for Nip34WritePolicy {
563 } 754 }
564 } 755 }
565 Err(e) => { 756 Err(e) => {
566 tracing::warn!( 757 tracing::warn!("Rejected repository announcement {}: {}", event_id_str, e);
567 "Rejected repository announcement {}: {}",
568 event_id_str,
569 e
570 );
571 PolicyResult::Reject(e.to_string()) 758 PolicyResult::Reject(e.to_string())
572 } 759 }
573 }, 760 },
@@ -577,7 +764,10 @@ impl WritePolicy for Nip34WritePolicy {
577 match RepositoryState::from_event(event.clone()) { 764 match RepositoryState::from_event(event.clone()) {
578 Ok(state) => { 765 Ok(state) => {
579 // Try to set HEAD for all authorized repos if this is the latest state 766 // Try to set HEAD for all authorized repos if this is the latest state
580 match self.try_set_head_for_authorized_repos(&database, &state).await { 767 match self
768 .try_set_head_for_authorized_repos(&database, &state)
769 .await
770 {
581 Ok(count) if count > 0 => { 771 Ok(count) if count > 0 => {
582 tracing::info!( 772 tracing::info!(
583 "Set HEAD from state event {} for {} repo(s) with identifier {}", 773 "Set HEAD from state event {} for {} repo(s) with identifier {}",
@@ -600,11 +790,8 @@ impl WritePolicy for Nip34WritePolicy {
600 ); 790 );
601 } 791 }
602 } 792 }
603 793
604 tracing::debug!( 794 tracing::debug!("Accepted repository state: {}", event_id_str);
605 "Accepted repository state: {}",
606 event_id_str
607 );
608 PolicyResult::Accept 795 PolicyResult::Accept
609 } 796 }
610 Err(e) => { 797 Err(e) => {
@@ -620,14 +807,104 @@ impl WritePolicy for Nip34WritePolicy {
620 } 807 }
621 } 808 }
622 Err(e) => { 809 Err(e) => {
810 tracing::warn!("Rejected repository state {}: {}", event_id_str, e);
811 PolicyResult::Reject(e.to_string())
812 }
813 },
814 // KIND_PR (1618) and KIND_PR_UPDATE (1619): Validate refs/nostr/<event-id> refs before acceptance
815 KIND_PR | KIND_PR_UPDATE => {
816 // Validate refs/nostr refs for this PR event
817 // This deletes any refs/nostr/<event-id> that points to wrong commit
818 if let Err(e) = self.validate_pr_nostr_ref(&database, event).await {
623 tracing::warn!( 819 tracing::warn!(
624 "Rejected repository state {}: {}", 820 "Failed to validate refs/nostr for PR event {}: {}",
625 event_id_str, 821 event_id_str,
626 e 822 e
627 ); 823 );
628 PolicyResult::Reject(e.to_string()) 824 // Don't reject - just log the error and proceed with normal validation
629 } 825 }
630 }, 826
827 // Continue with standard reference checking (same as default case)
828 let (addressable_refs, event_refs) = Self::extract_reference_tags(event);
829
830 // Check 1: Does this event reference an accepted repository?
831 match Self::find_accepted_repository(&database, &addressable_refs).await {
832 Ok(Some(addr_ref)) => {
833 tracing::debug!(
834 "Accepted PR event {}: references accepted repository {}",
835 event_id_str,
836 addr_ref
837 );
838 return PolicyResult::Accept;
839 }
840 Ok(None) => {
841 // No matching repositories, continue to next check
842 }
843 Err(e) => {
844 tracing::warn!(
845 "Database query failed for PR {}, rejecting (fail-secure): {}",
846 event_id_str,
847 e
848 );
849 return PolicyResult::Reject(format!("Database query failed: {}", e));
850 }
851 }
852
853 // Check 2: Does this event reference an accepted event?
854 match Self::find_accepted_event(&database, &event_refs).await {
855 Ok(Some(event_ref)) => {
856 tracing::debug!(
857 "Accepted PR event {}: references accepted event {}",
858 event_id_str,
859 event_ref
860 );
861 return PolicyResult::Accept;
862 }
863 Ok(None) => {
864 // No matching events, continue to next check
865 }
866 Err(e) => {
867 tracing::warn!(
868 "Database query failed for PR {}, rejecting (fail-secure): {}",
869 event_id_str,
870 e
871 );
872 return PolicyResult::Reject(format!("Database query failed: {}", e));
873 }
874 }
875
876 // Check 3: Is this event referenced by an accepted event?
877 match Self::is_referenced_by_accepted(&database, event).await {
878 Ok(true) => {
879 tracing::debug!(
880 "Accepted PR event {}: referenced by accepted event",
881 event_id_str
882 );
883 return PolicyResult::Accept;
884 }
885 Ok(false) => {
886 // No forward references found, continue to rejection
887 }
888 Err(e) => {
889 tracing::warn!(
890 "Database query failed for PR {}, rejecting (fail-secure): {}",
891 event_id_str,
892 e
893 );
894 return PolicyResult::Reject(format!("Database query failed: {}", e));
895 }
896 }
897
898 // No valid references found - reject as orphan event
899 tracing::info!(
900 "Rejected orphan PR event {}: no references to accepted repos or events",
901 event_id_str
902 );
903 PolicyResult::Reject(
904 "PR event must reference an accepted repository or accepted event"
905 .to_string(),
906 )
907 }
631 // GRASP-01: Check if event references accepted repositories or events 908 // GRASP-01: Check if event references accepted repositories or events
632 _ => { 909 _ => {
633 // Extract all reference tags from event 910 // Extract all reference tags from event
@@ -709,7 +986,7 @@ impl WritePolicy for Nip34WritePolicy {
709 event_refs.len() 986 event_refs.len()
710 ); 987 );
711 PolicyResult::Reject( 988 PolicyResult::Reject(
712 "Event must reference an accepted repository or accepted event".to_string() 989 "Event must reference an accepted repository or accepted event".to_string(),
713 ) 990 )
714 } 991 }
715 } 992 }
@@ -786,4 +1063,4 @@ pub fn create_relay(config: &Config) -> Result<RelayWithDatabase> {
786 relay: LocalRelay::new(builder), 1063 relay: LocalRelay::new(builder),
787 database, 1064 database,
788 }) 1065 })
789} \ No newline at end of file 1066}
diff --git a/src/nostr/events.rs b/src/nostr/events.rs
index 97688b1..6a62ccd 100644
--- a/src/nostr/events.rs
+++ b/src/nostr/events.rs
@@ -15,6 +15,12 @@ pub const KIND_REPOSITORY_ANNOUNCEMENT: u16 = 30617;
15/// NIP-34 Repository State Announcement (kind 30618) 15/// NIP-34 Repository State Announcement (kind 30618)
16pub const KIND_REPOSITORY_STATE: u16 = 30618; 16pub const KIND_REPOSITORY_STATE: u16 = 30618;
17 17
18/// NIP-34 Pull Request (kind 1618) - has `c` tag for commit
19pub const KIND_PR: u16 = 1618;
20
21/// NIP-34 Pull Request Update (kind 1619) - has `c` tag for commit
22pub const KIND_PR_UPDATE: u16 = 1619;
23
18/// Repository announcement details extracted from NIP-34 event 24/// Repository announcement details extracted from NIP-34 event
19#[derive(Debug, Clone)] 25#[derive(Debug, Clone)]
20pub struct RepositoryAnnouncement { 26pub struct RepositoryAnnouncement {