diff options
| author | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 11:56:49 +0000 |
|---|---|---|
| committer | DanConwayDev <DanConwayDev@protonmail.com> | 2025-12-01 11:58:34 +0000 |
| commit | 7a78815e29b01c83f3d0ec195ba717a2eba8cd37 (patch) | |
| tree | 4c5ccd9b812f1d1d75ed218501192ddc5459fd12 /src/git/mod.rs | |
| parent | e6ceab90de1acad154624022a6036efac18abab6 (diff) | |
reject push when refs/nostr/<event-id> doesnt match known event and delete incorrect ref on event receive
Diffstat (limited to 'src/git/mod.rs')
| -rw-r--r-- | src/git/mod.rs | 210 |
1 files changed, 157 insertions, 53 deletions
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}; | |||
| 40 | pub fn resolve_repo_path(git_data_path: &str, npub: &str, identifier: &str) -> PathBuf { | 40 | pub 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 { | |||
| 89 | pub fn set_repository_head(repo_path: &Path, head_ref: &str) -> Result<(), String> { | 89 | pub 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 | ||
| 166 | pub 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 | ||
| 188 | pub 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 | ||
| 219 | pub 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> { | |||
| 178 | pub fn parse_git_url(path: &str) -> Option<(&str, &str, &str)> { | 293 | pub 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 | } |