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
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
|
use std::collections::HashSet;
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_with_purgatory;
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
// Include purgatory announcements for authorization
let db_repo_data = fetch_repository_data_with_purgatory(
&self.ctx.database,
&self.ctx.purgatory,
&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"
);
// Extend expiry for any purgatory announcements for this identifier.
//
// Per design doc decision #4: state event arrival extends the purgatory
// announcement's expiry (reset the 30-minute protocol timer). This prevents
// premature expiry during slow sync operations — the repo is actively receiving
// metadata so it should stay alive.
//
// We extend for all owners that authorized this state event, since the state
// event proves the repo is active regardless of which owner's announcement
// authorized it.
for owner_hex in &authorized_owners {
if let Ok(owner_pk) = nostr_sdk::PublicKey::from_hex(owner_hex) {
if self.ctx.purgatory.has_purgatory_announcement(&owner_pk, &state.identifier) {
self.ctx.purgatory.extend_announcement_expiry(
&owner_pk,
&state.identifier,
std::time::Duration::from_secs(1800),
);
tracing::debug!(
event_id = %event.id,
identifier = %state.identifier,
owner = %owner_hex,
"Extended purgatory announcement expiry due to state event arrival"
);
}
}
}
// 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"
);
}
}
// After copying OIDs to other owner repos, promote any purgatory announcements
// for those repos. This handles the case where two maintainers push to the same
// identifier on the same relay with identical commit hashes: the second maintainer's
// announcement sits in purgatory, and when their state event arrives the relay copies
// commits from the first maintainer's repo — but without this call the announcement
// would stay in purgatory indefinitely.
let local_relay = self.ctx.get_local_relay();
let empty_oids: HashSet<String> = HashSet::new();
for announcement in &db_repo_data.announcements {
let target_repo_path = self.ctx.git_data_path.join(announcement.repo_path());
if target_repo_path != repo_with_git_data {
// OIDs were copied to this repo by process_state_with_git_data;
// check if there's a purgatory announcement waiting for it.
if let Err(e) = crate::git::sync::process_newly_available_git_data(
&target_repo_path,
&empty_oids,
&self.ctx.database,
local_relay.as_ref(),
&self.ctx.purgatory,
&self.ctx.git_data_path,
None,
None,
)
.await
{
tracing::warn!(
identifier = %state.identifier,
event_id = %event.id,
repo_path = %target_repo_path.display(),
error = %e,
"Failed to process purgatory announcements for target repo after git sync copy"
);
}
}
}
// 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);
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
}
|