1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
|
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use nostr_relay_builder::builder::WritePolicyResult;
/// State Policy - State event validation + ref alignment
///
/// Handles validation of NIP-34 repository state events (kind 30618)
/// and aligns git refs with authorized state according to GRASP-01.
use nostr_relay_builder::prelude::Event;
use super::PolicyContext;
use crate::git;
use crate::git::authorization::fetch_repository_data;
use crate::nostr::events::{validate_state, RepositoryAnnouncement, RepositoryState};
/// Result of state policy evaluation
#[derive(Debug)]
pub enum StateResult {
/// Accept: Event passes validation
Accept,
/// Reject: Event fails validation with reason
Reject(String),
}
/// Policy for validating repository state events and aligning refs
#[derive(Clone)]
pub struct StatePolicy {
ctx: PolicyContext,
}
impl StatePolicy {
pub fn new(ctx: PolicyContext) -> Self {
Self { ctx }
}
/// Validate a repository state event
pub fn validate(&self, event: &Event) -> StateResult {
match validate_state(event) {
Ok(_) => StateResult::Accept,
Err(e) => StateResult::Reject(e.to_string()),
}
}
/// Process a state event: validate and align owner repositories
///
/// # Arguments
/// * `event` - The state event to process
/// * `is_synced` - True if this event came from proactive sync (vs user-submitted)
///
/// Returns the true if git data already availale or false if added to purgatory
pub async fn process_state_event(
&self,
event: &Event,
is_synced: bool,
) -> Result<WritePolicyResult> {
// Parse state to get HEAD and branch info
let state =
RepositoryState::from_event(event.clone()).context("Failed to parse state event")?;
// Duplicate check in purgatory
if self
.ctx
.purgatory
.find_state(&state.identifier)
.iter()
.any(|e| e.event.id.eq(&event.id))
{
tracing::debug!(
"processed state event duplicate (already in purgatory): {}",
event.id,
);
return Ok(WritePolicyResult::Reject {
status: true,
message: "duplicate: in purgatory".into(),
});
}
// Get all repositories and state events from db with identifier
let db_repo_data = fetch_repository_data(&self.ctx.database, &state.identifier).await?;
// CRITICAL: Check if author is authorized via maintainer set
// State events MUST be rejected if author is not in maintainer set of any accepted announcement
if db_repo_data.announcements.is_empty() {
if is_synced {
tracing::debug!(
event_id = %event.id,
identifier = %state.identifier,
author = %event.pubkey.to_hex(),
"Rejecting state event: no announcement exists for this repository"
);
} else {
tracing::warn!(
event_id = %event.id,
identifier = %state.identifier,
author = %event.pubkey.to_hex(),
"Rejecting state event: no announcement exists for this repository"
);
}
return Ok(WritePolicyResult::Reject {
status: false,
message: "invalid: no announcement exists for this repository".into(),
});
}
let authorized_owners = crate::git::authorization::pubkey_authorised_for_repo_owners(
&event.pubkey,
&db_repo_data,
);
if authorized_owners.is_empty() {
if is_synced {
tracing::debug!(
event_id = %event.id,
identifier = %state.identifier,
author = %event.pubkey.to_hex(),
announcements_count = db_repo_data.announcements.len(),
"Rejecting state event: author not in maintainer set of any announcement"
);
} else {
tracing::warn!(
event_id = %event.id,
identifier = %state.identifier,
author = %event.pubkey.to_hex(),
announcements_count = db_repo_data.announcements.len(),
"Rejecting state event: author not in maintainer set of any announcement"
);
}
return Ok(WritePolicyResult::Reject {
status: false,
message: "invalid: author not authorized for this repository".into(),
});
}
tracing::debug!(
event_id = %event.id,
identifier = %state.identifier,
author = %event.pubkey.to_hex(),
authorized_for_owners = ?authorized_owners,
"State event author authorized via maintainer set"
);
// Duplicate check in db
if db_repo_data.states.iter().any(|e| e.event.id.eq(&event.id)) {
tracing::debug!("processed state event duplicate (in db): {}", event.id);
return Ok(WritePolicyResult::Reject {
status: true,
message: "duplicate".into(),
});
}
// Check if git data is available
if let Some(repo_with_git_data) =
find_repo_with_git_data(&db_repo_data.announcements, &state, &self.ctx.git_data_path)
{
tracing::debug!(
"processing state event as git data already available: {}",
event.id,
);
// Use unified processing function
let result = crate::git::process::process_state_with_git_data(
&state,
&repo_with_git_data,
&db_repo_data,
&self.ctx.git_data_path,
);
tracing::info!(
identifier = %state.identifier,
event_id = %event.id,
repos_synced = result.repos_synced,
refs_created = result.refs_created,
refs_updated = result.refs_updated,
refs_deleted = result.refs_deleted,
"Processed state event with git data already available"
);
if !result.errors.is_empty() {
for error in &result.errors {
tracing::warn!(
identifier = %state.identifier,
event_id = %event.id,
error = %error,
"Error processing state event"
);
}
}
// Event will be saved and broadcast by relay builder
Ok(WritePolicyResult::Accept)
} else {
// Only reject expired events if they're from sync (not user-submitted)
// User-submitted events should be allowed to retry in case git data became available
if is_synced && self.ctx.purgatory.is_expired(&event.id) {
tracing::debug!(
event_id = %event.id,
identifier = %state.identifier,
"State event previously expired from purgatory (synced), rejecting to prevent re-sync loop"
);
return Ok(WritePolicyResult::Reject {
status: false,
message: "invalid: previously expired from purgatory without git data".into(),
});
}
// If no git data - add to purgatory
// (add_state automatically enqueues for background sync)
self.ctx.purgatory.add_state(
event.clone(),
state.identifier.clone(),
event.pubkey,
is_synced,
);
tracing::info!(
"state event added to purgatory: eventid: {}, identifier: {}",
state.event.id,
state.identifier,
);
Ok(WritePolicyResult::Reject {
status: true,
message: "purgatory: won't be served until git data arrives".into(),
})
}
}
}
fn find_repo_with_git_data(
announcements: &[RepositoryAnnouncement],
state: &RepositoryState,
git_data_path: &Path,
) -> Option<PathBuf> {
for announcement in announcements {
let repo_path = git_data_path.join(announcement.repo_path().clone());
if state.branches.iter().all(|branch_state| {
if branch_state.commit.starts_with("ref: ") {
true // ignore symlinks
} else {
git::oid_exists(&repo_path, &branch_state.commit)
}
}) && state
.tags
.iter()
.all(|tag_state| git::oid_exists(&repo_path, &tag_state.commit))
{
return Some(repo_path);
}
}
None
}
|