upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src/nostr/policy/state.rs
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:42:00 +0000
committerDanConwayDev <DanConwayDev@protonmail.com>2025-12-04 15:42:00 +0000
commit819866330c7e2f535a155d1d7efaf2e12dc15dc2 (patch)
treed84c8361811544aad9cad089c0358b9028c8fb80 /src/nostr/policy/state.rs
parentfd0c87c787d0626b3546fa571541c9c809711821 (diff)
refactor: split Nip34WritePolicy into focused sub-policies
Split the ~900 line Nip34WritePolicy into focused sub-policies for improved testability and maintainability: - AnnouncementPolicy - Repository announcement validation - StatePolicy - State event validation + ref alignment - PrEventPolicy - PR/PR Update validation - RelatedEventPolicy - Forward/backward reference checking The main Nip34WritePolicy now delegates to these sub-policies via a shared PolicyContext that provides domain, database, and git_data_path. Also updates: - README.md: Accurate project structure reflecting actual implementation - docs/learnings: Marks this technical debt item as complete
Diffstat (limited to 'src/nostr/policy/state.rs')
-rw-r--r--src/nostr/policy/state.rs419
1 files changed, 419 insertions, 0 deletions
diff --git a/src/nostr/policy/state.rs b/src/nostr/policy/state.rs
new file mode 100644
index 0000000..5692bd8
--- /dev/null
+++ b/src/nostr/policy/state.rs
@@ -0,0 +1,419 @@
1/// State Policy - State event validation + ref alignment
2///
3/// Handles validation of NIP-34 repository state events (kind 30618)
4/// and aligns git refs with authorized state according to GRASP-01.
5use nostr_relay_builder::prelude::{Alphabet, Event, Filter, Kind, PublicKey, SingleLetterTag};
6
7use super::PolicyContext;
8use crate::git;
9use crate::nostr::events::{
10 validate_state, RepositoryAnnouncement, RepositoryState, KIND_REPOSITORY_ANNOUNCEMENT,
11 KIND_REPOSITORY_STATE,
12};
13
14/// Result of aligning a repository with authorized state
15#[derive(Debug, Default)]
16pub struct AlignmentResult {
17 /// Number of refs created
18 pub refs_created: usize,
19 /// Number of refs updated
20 pub refs_updated: usize,
21 /// Number of refs deleted
22 pub refs_deleted: usize,
23 /// Whether HEAD was set
24 pub head_set: bool,
25}
26
27impl AlignmentResult {
28 pub fn has_changes(&self) -> bool {
29 self.refs_created > 0 || self.refs_updated > 0 || self.refs_deleted > 0 || self.head_set
30 }
31}
32
33/// Result of state policy evaluation
34#[derive(Debug)]
35pub enum StateResult {
36 /// Accept: Event passes validation
37 Accept,
38 /// Reject: Event fails validation with reason
39 Reject(String),
40}
41
42/// Policy for validating repository state events and aligning refs
43#[derive(Clone)]
44pub struct StatePolicy {
45 ctx: PolicyContext,
46}
47
48impl StatePolicy {
49 pub fn new(ctx: PolicyContext) -> Self {
50 Self { ctx }
51 }
52
53 /// Validate a repository state event
54 pub fn validate(&self, event: &Event) -> StateResult {
55 match validate_state(event) {
56 Ok(_) => StateResult::Accept,
57 Err(e) => StateResult::Reject(e.to_string()),
58 }
59 }
60
61 /// Process a state event: validate and align owner repositories
62 ///
63 /// Returns the number of repositories aligned if successful.
64 pub async fn process_state_event(&self, event: &Event) -> Result<usize, String> {
65 // Parse state to get HEAD and branch info
66 let state = RepositoryState::from_event(event.clone())
67 .map_err(|e| format!("Failed to parse state: {}", e))?;
68
69 // Identify owner repositories for which this is the latest authorized state
70 let owner_repos = self.identify_owner_repositories(&state).await?;
71 let repo_count = owner_repos.len();
72 let mut total_aligned = 0;
73
74 // Align each owner repository with the authorized state
75 for (_announcement, repo_path) in owner_repos {
76 let result = self.align_repository_with_state(&repo_path, &state);
77
78 if result.has_changes() {
79 tracing::info!(
80 "Aligned {} with state: created={}, updated={}, deleted={}, head_set={}",
81 repo_path.display(),
82 result.refs_created,
83 result.refs_updated,
84 result.refs_deleted,
85 result.head_set
86 );
87 total_aligned += 1;
88 }
89 }
90
91 if repo_count > 0 {
92 tracing::info!(
93 "Processed state event for {} repo(s) ({} aligned) with identifier {}",
94 repo_count,
95 total_aligned,
96 state.identifier
97 );
98 } else {
99 tracing::debug!(
100 "No owner repos to align for state - git data not available yet or not latest"
101 );
102 }
103
104 Ok(total_aligned)
105 }
106
107 /// Check if this state event is the latest for its identifier among authorized authors
108 ///
109 /// A state is considered "latest" if no other state event in the database
110 /// from an authorized author has a newer timestamp.
111 async fn is_latest_state_for_identifier(
112 &self,
113 state: &RepositoryState,
114 authorized_pubkeys: &[PublicKey],
115 ) -> Result<bool, String> {
116 let filter = Filter::new()
117 .kind(Kind::from(KIND_REPOSITORY_STATE))
118 .custom_tag(
119 SingleLetterTag::lowercase(Alphabet::D),
120 state.identifier.clone(),
121 );
122
123 match self.ctx.database.query(filter).await {
124 Ok(events) => {
125 for event in events {
126 // Skip comparing to self (same event ID)
127 if event.id == state.event.id {
128 continue;
129 }
130 // Only consider events from authorized authors for this announcement
131 if !authorized_pubkeys.contains(&event.pubkey) {
132 continue;
133 }
134 // If any existing event from an authorized author is newer, this is not the latest
135 if event.created_at > state.event.created_at {
136 tracing::debug!(
137 "State {} is not latest: found newer state {} from {} (ts {} > {})",
138 state.event.id.to_hex(),
139 event.id.to_hex(),
140 event.pubkey.to_hex(),
141 event.created_at.as_secs(),
142 state.event.created_at.as_secs()
143 );
144 return Ok(false);
145 }
146 }
147 Ok(true)
148 }
149 Err(e) => Err(format!("Database query failed: {}", e)),
150 }
151 }
152
153 /// Find all repository announcements where the given pubkey is authorized
154 async fn find_authorized_announcements(
155 &self,
156 identifier: &str,
157 state_author: &PublicKey,
158 ) -> Result<Vec<RepositoryAnnouncement>, String> {
159 let filter = Filter::new()
160 .kind(Kind::from(KIND_REPOSITORY_ANNOUNCEMENT))
161 .custom_tag(
162 SingleLetterTag::lowercase(Alphabet::D),
163 identifier.to_string(),
164 );
165
166 match self.ctx.database.query(filter).await {
167 Ok(events) => {
168 let mut authorized = Vec::new();
169 let state_author_hex = state_author.to_hex();
170
171 for event in events {
172 if let Ok(announcement) = RepositoryAnnouncement::from_event(event.clone()) {
173 // Check if state author is authorized for this announcement
174 let is_owner = event.pubkey == *state_author;
175 let is_maintainer = announcement.maintainers.contains(&state_author_hex);
176
177 if is_owner || is_maintainer {
178 tracing::debug!(
179 "Found authorized announcement for {}: owner={}, maintainer={}",
180 identifier,
181 if is_owner {
182 event.pubkey.to_hex()
183 } else {
184 "n/a".to_string()
185 },
186 is_maintainer
187 );
188 authorized.push(announcement);
189 }
190 }
191 }
192 Ok(authorized)
193 }
194 Err(e) => Err(format!("Database query failed: {}", e)),
195 }
196 }
197
198 /// Identify all owner repositories for which this state event is the latest authorized state
199 async fn identify_owner_repositories(
200 &self,
201 state: &RepositoryState,
202 ) -> Result<Vec<(RepositoryAnnouncement, std::path::PathBuf)>, String> {
203 // Find all announcements where state author is authorized
204 let announcements = self
205 .find_authorized_announcements(&state.identifier, &state.event.pubkey)
206 .await?;
207
208 if announcements.is_empty() {
209 tracing::debug!(
210 "No authorized announcements found for state {} by {}",
211 state.identifier,
212 state.event.pubkey.to_hex()
213 );
214 return Ok(Vec::new());
215 }
216
217 let mut owner_repos = Vec::new();
218
219 for announcement in announcements {
220 // Build the list of authorized pubkeys for this specific announcement
221 let mut authorized_pubkeys = vec![announcement.event.pubkey];
222 for maintainer_hex in &announcement.maintainers {
223 if let Ok(pk) = PublicKey::from_hex(maintainer_hex) {
224 authorized_pubkeys.push(pk);
225 }
226 }
227
228 // Check if this is the latest state event for THIS announcement's context
229 if !self
230 .is_latest_state_for_identifier(state, &authorized_pubkeys)
231 .await?
232 {
233 tracing::debug!(
234 "Skipping {} in {}'s repo - not the latest state event for this context",
235 state.identifier,
236 announcement.event.pubkey.to_hex()
237 );
238 continue;
239 }
240
241 // Build repository path: <git_data_path>/<owner_npub>/<identifier>.git
242 let repo_path = self.ctx.git_data_path.join(announcement.repo_path().clone());
243 owner_repos.push((announcement, repo_path));
244 }
245
246 Ok(owner_repos)
247 }
248
249 /// Align a repository's refs with the authorized state
250 ///
251 /// This function:
252 /// 1. Deletes refs that are in the repo but not in the state (for refs/heads/ and refs/tags/)
253 /// 2. Updates refs that exist in state if we have the commit
254 /// 3. Sets HEAD if the HEAD branch's commit is available
255 pub fn align_repository_with_state(
256 &self,
257 repo_path: &std::path::Path,
258 state: &RepositoryState,
259 ) -> AlignmentResult {
260 let mut result = AlignmentResult::default();
261
262 // Check if repository exists
263 if !repo_path.exists() {
264 tracing::debug!(
265 "Repository not found at {}, cannot align with state",
266 repo_path.display()
267 );
268 return result;
269 }
270
271 // Get current refs from the repository
272 let current_refs = match git::list_refs(repo_path) {
273 Ok(refs) => refs,
274 Err(e) => {
275 tracing::warn!("Failed to list refs in {}: {}", repo_path.display(), e);
276 return result;
277 }
278 };
279
280 // Build expected refs from state
281 let mut expected_refs: std::collections::HashMap<String, String> =
282 std::collections::HashMap::new();
283
284 for branch in &state.branches {
285 let ref_name = format!("refs/heads/{}", branch.name);
286 expected_refs.insert(ref_name, branch.commit.clone());
287 }
288
289 for tag in &state.tags {
290 let ref_name = format!("refs/tags/{}", tag.name);
291 expected_refs.insert(ref_name, tag.commit.clone());
292 }
293
294 // Process current refs: update or delete as needed
295 for (ref_name, current_commit) in &current_refs {
296 // Only process refs/heads/ and refs/tags/
297 if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
298 continue;
299 }
300
301 match expected_refs.get(ref_name) {
302 Some(expected_commit) => {
303 // Ref should exist - check if commit matches
304 if current_commit != expected_commit {
305 // Check if we have the expected commit
306 if git::commit_exists(repo_path, expected_commit) {
307 // Update the ref
308 match git::update_ref(repo_path, ref_name, expected_commit) {
309 Ok(()) => {
310 tracing::info!(
311 "Updated {} from {} to {} in {}",
312 ref_name,
313 current_commit,
314 expected_commit,
315 repo_path.display()
316 );
317 result.refs_updated += 1;
318 }
319 Err(e) => {
320 tracing::warn!(
321 "Failed to update {} in {}: {}",
322 ref_name,
323 repo_path.display(),
324 e
325 );
326 }
327 }
328 } else {
329 tracing::debug!(
330 "Commit {} not available for {} in {}",
331 expected_commit,
332 ref_name,
333 repo_path.display()
334 );
335 }
336 }
337 }
338 None => {
339 // Ref should not exist - delete it
340 match git::delete_ref(repo_path, ref_name) {
341 Ok(()) => {
342 tracing::info!(
343 "Deleted {} (not in state) from {}",
344 ref_name,
345 repo_path.display()
346 );
347 result.refs_deleted += 1;
348 }
349 Err(e) => {
350 tracing::warn!(
351 "Failed to delete {} from {}: {}",
352 ref_name,
353 repo_path.display(),
354 e
355 );
356 }
357 }
358 }
359 }
360 }
361
362 // Add refs that exist in state but not in repo (if we have the commit)
363 for (ref_name, expected_commit) in &expected_refs {
364 let exists = current_refs.iter().any(|(r, _)| r == ref_name);
365 if !exists && git::commit_exists(repo_path, expected_commit) {
366 match git::update_ref(repo_path, ref_name, expected_commit) {
367 Ok(()) => {
368 tracing::info!(
369 "Created {} at {} in {}",
370 ref_name,
371 expected_commit,
372 repo_path.display()
373 );
374 result.refs_created += 1;
375 }
376 Err(e) => {
377 tracing::warn!(
378 "Failed to create {} in {}: {}",
379 ref_name,
380 repo_path.display(),
381 e
382 );
383 }
384 }
385 }
386 }
387
388 // Set HEAD if specified in state
389 if let Some(head_ref) = &state.head {
390 if let Some(branch_name) = state.get_head_branch() {
391 if let Some(head_commit) = state.get_branch_commit(branch_name) {
392 match git::try_set_head_if_available(repo_path, head_ref, head_commit) {
393 Ok(true) => {
394 tracing::info!(
395 "Set HEAD to {} in {} (from state by {})",
396 head_ref,
397 repo_path.display(),
398 state.event.pubkey.to_hex()
399 );
400 result.head_set = true;
401 }
402 Ok(false) => {
403 tracing::debug!(
404 "HEAD commit {} not available yet in {}",
405 head_commit,
406 repo_path.display()
407 );
408 }
409 Err(e) => {
410 tracing::warn!("Failed to set HEAD in {}: {}", repo_path.display(), e);
411 }
412 }
413 }
414 }
415 }
416
417 result
418 }
419} \ No newline at end of file