upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/purgatory/helpers.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-24 08:02:12 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-24 11:54:18 +0000
commit70d0197e85ae4ef85202781f6d2dc9e76bd508b3 (patch)
tree45efb6565e81ba755acc5955e68d5b7119d1e122 /src/purgatory/helpers.rs
parentf8c3e3920ed2a1bdaab30be912276993449a5476 (diff)
feat(purgatory): add broken purgatory implementation
Diffstat (limited to 'src/purgatory/helpers.rs')
-rw-r--r--src/purgatory/helpers.rs435
1 files changed, 435 insertions, 0 deletions
diff --git a/src/purgatory/helpers.rs b/src/purgatory/helpers.rs
new file mode 100644
index 0000000..5df6cc8
--- /dev/null
+++ b/src/purgatory/helpers.rs
@@ -0,0 +1,435 @@
1//! Helper functions for purgatory state event processing.
2//!
3//! These functions handle the late-binding extraction and matching of git refs
4//! from state events. Refs are extracted at git push time rather than event
5//! arrival time to enable flexible matching logic.
6
7use super::{RefPair, RefUpdate};
8use nostr_sdk::prelude::*;
9use std::collections::HashMap;
10
11/// Extract ref pairs from a state event (kind 30618).
12///
13/// Parses all `refs/heads/*` and `refs/tags/*` tags from the event,
14/// creating RefPair instances with the full ref name and target object SHA.
15///
16/// # Arguments
17/// * `event` - The state event to extract refs from
18///
19/// # Returns
20/// Vector of RefPair instances, one for each ref tag found
21///
22/// # Tag Format
23/// State events use custom tags where the tag kind is the ref name:
24/// - Tag kind: "refs/heads/main" or "refs/tags/v1.0"
25/// - First value: commit SHA or annotated tag SHA
26///
27/// # Example
28/// ```ignore
29/// // Event with tags:
30/// // ["refs/heads/main", "abc123..."]
31/// // ["refs/tags/v1.0", "def456..."]
32/// let refs = extract_refs_from_state(&event);
33/// // Returns: [
34/// // RefPair { ref_name: "refs/heads/main", object_sha: "abc123..." },
35/// // RefPair { ref_name: "refs/tags/v1.0", object_sha: "def456..." }
36/// // ]
37/// ```
38pub fn extract_refs_from_state(event: &Event) -> Vec<RefPair> {
39 event
40 .tags
41 .iter()
42 .filter_map(|tag| {
43 // Check if this is a custom tag with a ref name
44 if let TagKind::Custom(ref_name) = tag.kind() {
45 let ref_str = ref_name.as_ref();
46
47 // Only process refs/heads/* and refs/tags/*
48 if ref_str.starts_with("refs/heads/") || ref_str.starts_with("refs/tags/") {
49 // Get the object SHA (first value in tag)
50 let parts = tag.clone().to_vec();
51 if parts.len() >= 2 {
52 return Some(RefPair {
53 ref_name: ref_str.to_string(),
54 object_sha: parts[1].clone(),
55 });
56 }
57 }
58 }
59 None
60 })
61 .collect()
62}
63
64/// Check if a state event can be satisfied by ref updates plus local refs.
65///
66/// Returns true if applying the ref updates to local state results in exactly
67/// the state declared in the event. This means:
68/// 1. Filter local_refs to only branches (refs/heads/*) and tags (refs/tags/*)
69/// 2. Apply pushed_updates to create a "would-be" state
70/// 3. Compare would-be state with event's declared state - must match exactly
71///
72/// This implements correct authorization: the push must transform local state
73/// into the declared state, accounting for additions, deletions, and modifications.
74///
75/// # Arguments
76/// * `event` - The state event to check
77/// * `pushed_updates` - Ref updates in the current push operation
78/// * `local_refs` - Refs already existing locally (ref_name -> SHA)
79///
80/// # Returns
81/// true if push transforms local state into declared state, false otherwise
82///
83/// # Example
84/// ```ignore
85/// // State event declares: refs/heads/main@abc123
86/// // Local: refs/heads/main@old123, refs/heads/dev@def456
87/// // Push updates: main old123->abc123, dev def456->0000 (delete)
88/// // Result: false (event doesn't declare dev deletion)
89/// ```
90pub fn can_satisfy_state(
91 event: &Event,
92 pushed_updates: &[RefUpdate],
93 local_refs: &HashMap<String, String>,
94) -> bool {
95 let state_refs = extract_refs_from_state(event);
96
97 // Filter local_refs to only branches and tags
98 let mut would_be_state: HashMap<String, String> = local_refs
99 .iter()
100 .filter(|(ref_name, _)| {
101 ref_name.starts_with("refs/heads/") || ref_name.starts_with("refs/tags/")
102 })
103 .map(|(k, v)| (k.clone(), v.clone()))
104 .collect();
105
106 // Apply all pushed updates to create the would-be state
107 for update in pushed_updates {
108 // Only process branches and tags
109 if !update.ref_name.starts_with("refs/heads/") && !update.ref_name.starts_with("refs/tags/")
110 {
111 continue;
112 }
113
114 if update.is_deletion() {
115 // Remove from would-be state
116 would_be_state.remove(&update.ref_name);
117 } else {
118 // Create or modify in would-be state
119 would_be_state.insert(update.ref_name.clone(), update.new_oid.clone());
120 }
121 }
122
123 // Convert event's state refs to a HashMap for comparison
124 let declared_state: HashMap<String, String> = state_refs
125 .into_iter()
126 .map(|r| (r.ref_name, r.object_sha))
127 .collect();
128
129 // would_be_state must exactly match declared_state
130 would_be_state == declared_state
131}
132
133/// Get refs from state event that aren't in pushed_refs.
134///
135/// Returns refs that need to be present but aren't being pushed.
136/// These refs should exist in local_refs for the state to be satisfiable.
137/// Useful for error messages showing what's missing.
138///
139/// # Arguments
140/// * `event` - The state event to check
141/// * `pushed_refs` - Refs being pushed in the current operation
142///
143/// # Returns
144/// Vector of RefPair instances for refs not in pushed_refs
145///
146/// # Example
147/// ```ignore
148/// // State event declares: refs/heads/main@abc123, refs/heads/dev@def456
149/// // Pushed: refs/heads/main@abc123
150/// // Result: [RefPair { ref_name: "refs/heads/dev", object_sha: "def456" }]
151/// ```
152pub fn get_unpushed_refs(event: &Event, pushed_refs: &[RefPair]) -> Vec<RefPair> {
153 let state_refs = extract_refs_from_state(event);
154
155 state_refs
156 .into_iter()
157 .filter(|state_ref| {
158 // Include if NOT in pushed_refs (by name and SHA)
159 !pushed_refs.iter().any(|pushed_ref| {
160 pushed_ref.ref_name == state_ref.ref_name
161 && pushed_ref.object_sha == state_ref.object_sha
162 })
163 })
164 .collect()
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use nostr_sdk::{EventBuilder, Keys, Tag};
171
172 fn create_test_state_event(identifier: &str, refs: Vec<(&str, &str)>) -> Event {
173 let keys = Keys::generate();
174 let mut tags = vec![Tag::custom(TagKind::d(), vec![identifier.to_string()])];
175
176 for (ref_name, sha) in refs {
177 tags.push(Tag::custom(
178 TagKind::custom(ref_name),
179 vec![sha.to_string()],
180 ));
181 }
182
183 EventBuilder::new(Kind::from(30618), "")
184 .tags(tags)
185 .sign_with_keys(&keys)
186 .unwrap()
187 }
188
189 #[test]
190 fn test_extract_refs_from_state() {
191 let event = create_test_state_event(
192 "test-repo",
193 vec![
194 ("refs/heads/main", "abc123"),
195 ("refs/heads/dev", "def456"),
196 ("refs/tags/v1.0", "789xyz"),
197 ],
198 );
199
200 let refs = extract_refs_from_state(&event);
201
202 assert_eq!(refs.len(), 3);
203 assert!(refs
204 .iter()
205 .any(|r| r.ref_name == "refs/heads/main" && r.object_sha == "abc123"));
206 assert!(refs
207 .iter()
208 .any(|r| r.ref_name == "refs/heads/dev" && r.object_sha == "def456"));
209 assert!(refs
210 .iter()
211 .any(|r| r.ref_name == "refs/tags/v1.0" && r.object_sha == "789xyz"));
212 }
213
214 #[test]
215 fn test_extract_refs_ignores_non_ref_tags() {
216 let keys = Keys::generate();
217 let tags = vec![
218 Tag::custom(TagKind::d(), vec!["test-repo".to_string()]),
219 Tag::custom(
220 TagKind::custom("refs/heads/main"),
221 vec!["abc123".to_string()],
222 ),
223 Tag::custom(TagKind::custom("some-other-tag"), vec!["value".to_string()]),
224 ];
225
226 let event = EventBuilder::new(Kind::from(30618), "")
227 .tags(tags)
228 .sign_with_keys(&keys)
229 .unwrap();
230
231 let refs = extract_refs_from_state(&event);
232
233 // Should only extract the refs/heads/main tag
234 assert_eq!(refs.len(), 1);
235 assert_eq!(refs[0].ref_name, "refs/heads/main");
236 }
237
238 #[test]
239 fn test_can_satisfy_state_all_in_pushed() {
240 let event = create_test_state_event(
241 "test-repo",
242 vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
243 );
244
245 let pushed_updates = vec![
246 RefUpdate {
247 old_oid: "0000000000000000000000000000000000000000".to_string(),
248 new_oid: "abc123".to_string(),
249 ref_name: "refs/heads/main".to_string(),
250 },
251 RefUpdate {
252 old_oid: "0000000000000000000000000000000000000000".to_string(),
253 new_oid: "def456".to_string(),
254 ref_name: "refs/heads/dev".to_string(),
255 },
256 ];
257
258 let local_refs = HashMap::new();
259
260 assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
261 }
262
263 #[test]
264 fn test_can_satisfy_state_split_between_pushed_and_local() {
265 let event = create_test_state_event(
266 "test-repo",
267 vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
268 );
269
270 let pushed_updates = vec![RefUpdate {
271 old_oid: "0000000000000000000000000000000000000000".to_string(),
272 new_oid: "abc123".to_string(),
273 ref_name: "refs/heads/main".to_string(),
274 }];
275
276 let mut local_refs = HashMap::new();
277 local_refs.insert("refs/heads/dev".to_string(), "def456".to_string());
278
279 assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
280 }
281
282 #[test]
283 fn test_can_satisfy_state_missing_ref() {
284 let event = create_test_state_event(
285 "test-repo",
286 vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
287 );
288
289 let pushed_updates = vec![RefUpdate {
290 old_oid: "0000000000000000000000000000000000000000".to_string(),
291 new_oid: "abc123".to_string(),
292 ref_name: "refs/heads/main".to_string(),
293 }];
294
295 let local_refs = HashMap::new();
296
297 // dev ref is missing
298 assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs));
299 }
300
301 #[test]
302 fn test_can_satisfy_state_modification() {
303 let event = create_test_state_event(
304 "test-repo",
305 vec![("refs/heads/main", "abc123"), ("refs/heads/dev", "def456")],
306 );
307
308 let pushed_updates = vec![
309 RefUpdate {
310 old_oid: "old123".to_string(),
311 new_oid: "abc123".to_string(),
312 ref_name: "refs/heads/main".to_string(),
313 },
314 RefUpdate {
315 old_oid: "wrong-sha".to_string(),
316 new_oid: "def456".to_string(),
317 ref_name: "refs/heads/dev".to_string(),
318 },
319 ];
320
321 let mut local_refs = HashMap::new();
322 local_refs.insert("refs/heads/main".to_string(), "old123".to_string());
323 local_refs.insert("refs/heads/dev".to_string(), "wrong-sha".to_string());
324
325 // Should succeed because push updates both to match event
326 assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
327 }
328
329 #[test]
330 fn test_can_satisfy_state_rejects_extra_refs() {
331 let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
332
333 let pushed_updates = vec![
334 RefUpdate {
335 old_oid: "0000000000000000000000000000000000000000".to_string(),
336 new_oid: "abc123".to_string(),
337 ref_name: "refs/heads/main".to_string(),
338 },
339 RefUpdate {
340 old_oid: "old456".to_string(),
341 new_oid: "def456".to_string(),
342 ref_name: "refs/heads/dev".to_string(),
343 },
344 ];
345
346 let mut local_refs = HashMap::new();
347 local_refs.insert("refs/heads/dev".to_string(), "old456".to_string());
348
349 // Should fail because event doesn't declare dev
350 assert!(!can_satisfy_state(&event, &pushed_updates, &local_refs));
351 }
352
353 #[test]
354 fn test_can_satisfy_state_filters_non_branch_tag_refs() {
355 let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
356
357 let pushed_updates = vec![RefUpdate {
358 old_oid: "0000000000000000000000000000000000000000".to_string(),
359 new_oid: "abc123".to_string(),
360 ref_name: "refs/heads/main".to_string(),
361 }];
362
363 let mut local_refs = HashMap::new();
364 // Add some non-branch/non-tag refs that should be filtered out
365 local_refs.insert("refs/pull/123/head".to_string(), "xyz789".to_string());
366 local_refs.insert("refs/some/other/thing".to_string(), "aaa111".to_string());
367
368 // Should succeed - non-branch/tag refs are filtered out
369 assert!(can_satisfy_state(&event, &pushed_updates, &local_refs));
370 }
371
372 #[test]
373 fn test_can_satisfy_state_empty_event() {
374 let event = create_test_state_event("test-repo", vec![]);
375 let pushed_refs = vec![];
376 let local_refs = HashMap::new();
377
378 // Empty state event is satisfied
379 assert!(can_satisfy_state(&event, &pushed_refs, &local_refs));
380 }
381
382 #[test]
383 fn test_get_unpushed_refs() {
384 let event = create_test_state_event(
385 "test-repo",
386 vec![
387 ("refs/heads/main", "abc123"),
388 ("refs/heads/dev", "def456"),
389 ("refs/tags/v1.0", "789xyz"),
390 ],
391 );
392
393 let pushed_refs = vec![RefPair {
394 ref_name: "refs/heads/main".to_string(),
395 object_sha: "abc123".to_string(),
396 }];
397
398 let unpushed = get_unpushed_refs(&event, &pushed_refs);
399
400 assert_eq!(unpushed.len(), 2);
401 assert!(unpushed.iter().any(|r| r.ref_name == "refs/heads/dev"));
402 assert!(unpushed.iter().any(|r| r.ref_name == "refs/tags/v1.0"));
403 }
404
405 #[test]
406 fn test_get_unpushed_refs_all_pushed() {
407 let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
408
409 let pushed_refs = vec![RefPair {
410 ref_name: "refs/heads/main".to_string(),
411 object_sha: "abc123".to_string(),
412 }];
413
414 let unpushed = get_unpushed_refs(&event, &pushed_refs);
415
416 assert_eq!(unpushed.len(), 0);
417 }
418
419 #[test]
420 fn test_get_unpushed_refs_sha_mismatch() {
421 let event = create_test_state_event("test-repo", vec![("refs/heads/main", "abc123")]);
422
423 let pushed_refs = vec![RefPair {
424 ref_name: "refs/heads/main".to_string(),
425 object_sha: "different-sha".to_string(), // Different SHA
426 }];
427
428 let unpushed = get_unpushed_refs(&event, &pushed_refs);
429
430 // Should still be unpushed because SHA doesn't match
431 assert_eq!(unpushed.len(), 1);
432 assert_eq!(unpushed[0].ref_name, "refs/heads/main");
433 assert_eq!(unpushed[0].object_sha, "abc123");
434 }
435}