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
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
|
//! Archive Read-Only Mode Integration Tests
//!
//! Tests that verify archive_read_only mode behavior:
//! - Bare git repositories are created for announcements
//! - Git data is synced via relay-to-relay sync (purgatory sync)
//! - Git pushes are rejected (read-only mode)
//!
//! # Test Strategy
//!
//! These tests verify the GRASP-05 archive mode with read_only flag:
//! 1. Source relay has full repository (announcement + state events + git data)
//! 2. Archive relay syncs from source relay (relay-to-relay sync)
//! 3. State events trigger purgatory sync which fetches git data
//! 4. Git data is validated against Nostr state events
//! 5. Git pushes are rejected (read-only enforcement)
//!
//! # Security Model
//!
//! Archive mode uses the existing purgatory sync infrastructure to ensure:
//! - Git data is validated against Nostr state events
//! - "Naughty git servers" can't provide incorrect state
//! - Same security guarantees as normal relay operation
//!
//! # Running Tests
//!
//! ```bash
//! # Run all archive read-only tests
//! cargo test --test archive_read_only
//!
//! # Run specific test
//! cargo test --test archive_read_only test_archive_read_only_creates_bare_repo
//!
//! # With output for debugging
//! cargo test --test archive_read_only -- --nocapture
//! ```
mod common;
use common::{
check_ref_at_commit, create_repo_announcement, create_state_event,
create_test_repo_with_commit, push_to_relay, wait_for_event_served, wait_for_sync_connection,
CommitVariant, TestRelay,
};
use nostr_sdk::prelude::*;
use std::time::Duration;
/// Test that archive_read_only mode creates bare git repositories and syncs data
/// via relay-to-relay sync (purgatory sync infrastructure).
///
/// Scenario:
/// 1. Start source relay with full repository (announcement + state + git data)
/// 2. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
/// 3. Archive relay syncs announcement and state events from source
/// 4. State events trigger purgatory sync which fetches git data from source's clone URL
/// 5. Verify bare repository is created and git data is synced
/// 6. Verify git pushes are rejected (read-only mode)
#[tokio::test]
async fn test_archive_read_only_creates_bare_repo() {
// 1. Start source relay
let source_relay = TestRelay::start().await;
let keys = Keys::generate();
let identifier = "archive-test-repo";
// Pre-allocate archive relay port so we can include it in announcement
let archive_port = TestRelay::find_free_port();
let archive_domain = format!("127.0.0.1:{}", archive_port);
// 2. Create test repository locally with deterministic commit
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
.expect("Failed to create test repo");
let npub = keys.public_key().to_bech32().expect("Failed to get npub");
// 3. Create and send announcement listing BOTH relays
// This ensures the archive relay will accept the state event when it syncs
let announcement = create_repo_announcement(
&keys,
&[&source_relay.domain(), &archive_domain],
identifier,
);
let source_client = Client::new(keys.clone());
source_client
.add_relay(source_relay.url())
.await
.expect("Failed to add source relay");
source_client.connect().await;
// Wait for connection
tokio::time::sleep(Duration::from_millis(500)).await;
// Send announcement to source relay
source_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 4. Create and send state event
let clone_urls = [
format!(
"http://{}/{}/{}.git",
source_relay.domain(),
npub,
identifier
),
format!("http://{}/{}/{}.git", archive_domain, npub, identifier),
];
let relay_urls = [
source_relay.url().to_string(),
format!("ws://{}", archive_domain),
];
let state_event = create_state_event(
&keys,
identifier,
&[("main", &commit_hash)],
&[],
&[&clone_urls[0], &clone_urls[1]],
&[&relay_urls[0], &relay_urls[1]],
)
.expect("Failed to create state event");
let state_event_id = state_event.id;
// Send state event to source relay (goes to purgatory - no git data yet)
source_client
.send_event(&state_event)
.await
.expect("Failed to send state event to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 5. Push git data to source relay
// The state event in purgatory authorizes this push
push_to_relay(temp_dir.path(), &source_relay.domain(), &npub, identifier)
.expect("Push to source should succeed");
// After push, state event should be released from purgatory on source relay
wait_for_event_served(source_relay.url(), &state_event_id, Duration::from_secs(5))
.await
.expect("State event should be served on source relay after push");
// 6. Start archive relay with archive_all=true, archive_read_only=true, syncing from source
let archive_relay = TestRelay::start_with_archive_and_sync(
archive_port,
Some(source_relay.url().to_string()),
false, // negentropy enabled
true, // archive_all
true, // archive_read_only
)
.await;
// Wait for sync connection to establish
wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
.await
.expect("Sync connection should establish");
// 7. Wait for state event to be released on archive relay
// The sync should:
// a) Fetch the announcement and state event from source relay
// b) Accept announcement (creates bare repo structure) - via archive mode
// c) Put state event in purgatory (git data missing on archive relay)
// d) Fetch git data from source relay's clone URL
// e) Release the state event from purgatory
let found = wait_for_event_served(
archive_relay.url(),
&state_event_id,
Duration::from_secs(30), // Allow time for sync + git fetch
)
.await;
assert!(
found.is_ok(),
"State event should be served after sync fetches git data: {:?}",
found.err()
);
// 8. Verify bare repository was created
let repo_path = archive_relay
.git_data_path()
.join(format!("{}/{}.git", npub, identifier));
assert!(
repo_path.exists(),
"Bare repository should be created at {:?} for archive announcement",
repo_path
);
// 9. Verify it's a bare repository (check for config file with bare = true)
let config_path = repo_path.join("config");
assert!(
config_path.exists(),
"Git config should exist at {:?}",
config_path
);
let config_content = tokio::fs::read_to_string(&config_path)
.await
.expect("Should read git config");
assert!(
config_content.contains("bare = true"),
"Repository at {:?} should be bare (config should contain 'bare = true')",
repo_path
);
// 10. Verify refs are correct on archive relay
let ref_correct = check_ref_at_commit(
&archive_domain,
&npub,
identifier,
"refs/heads/main",
&commit_hash,
)
.await
.expect("Failed to check ref");
assert!(ref_correct, "main branch should point to correct commit");
// 11. Verify git pushes are rejected (read-only mode)
// Create a new commit in the source repo
tokio::fs::write(temp_dir.path().join("new_file.txt"), "new content")
.await
.expect("Failed to write new file");
let output = tokio::process::Command::new("git")
.args(["add", "."])
.current_dir(temp_dir.path())
.output()
.await
.expect("Failed to git add");
assert!(output.status.success());
let output = tokio::process::Command::new("git")
.args(["commit", "-m", "New commit for push test"])
.current_dir(temp_dir.path())
.output()
.await
.expect("Failed to git commit");
assert!(output.status.success());
// Try to push to archive relay (should fail in read-only mode)
let push_url = format!("http://{}/{}/{}.git", archive_domain, npub, identifier);
let output = tokio::process::Command::new("git")
.args(["push", &push_url, "main"])
.current_dir(temp_dir.path())
.output()
.await
.expect("Failed to run git push");
assert!(
!output.status.success(),
"Git push should be rejected in archive_read_only mode. stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// Cleanup
source_client.disconnect().await;
archive_relay.stop().await;
source_relay.stop().await;
}
/// Test that archive mode without state events does NOT sync git data.
///
/// This verifies the security model: archive mode only syncs git data
/// when there are state events to validate against.
///
/// Scenario:
/// 1. Start source relay with announcement only (no state events)
/// 2. Start archive relay syncing from source
/// 3. Archive relay syncs announcement (creates bare repo)
/// 4. Verify git data is NOT synced (no state events to trigger purgatory sync)
#[tokio::test]
async fn test_archive_without_state_events_does_not_sync_git() {
// 1. Start source relay
let source_relay = TestRelay::start().await;
let keys = Keys::generate();
let identifier = "archive-no-state-repo";
// Pre-allocate archive relay port
let archive_port = TestRelay::find_free_port();
let archive_domain = format!("127.0.0.1:{}", archive_port);
// 2. Create test repository locally
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
.expect("Failed to create test repo");
let npub = keys.public_key().to_bech32().expect("Failed to get npub");
// 3. Create and send announcement listing BOTH relays (but NO state event)
let announcement = create_repo_announcement(
&keys,
&[&source_relay.domain(), &archive_domain],
identifier,
);
let source_client = Client::new(keys.clone());
source_client
.add_relay(source_relay.url())
.await
.expect("Failed to add source relay");
source_client.connect().await;
tokio::time::sleep(Duration::from_millis(500)).await;
// Send announcement to source relay
source_client
.send_event(&announcement)
.await
.expect("Failed to send announcement to source");
tokio::time::sleep(Duration::from_millis(200)).await;
// 4. Push git data to source relay (but no state event to authorize it)
// This push will fail because there's no state event in purgatory
// That's expected - we're testing that archive mode doesn't blindly fetch git data
// 5. Start archive relay
let archive_relay = TestRelay::start_with_archive_and_sync(
archive_port,
Some(source_relay.url().to_string()),
false,
true,
true,
)
.await;
// Wait for sync
wait_for_sync_connection(archive_relay.url(), 1, Duration::from_secs(5))
.await
.expect("Sync connection should establish");
// Give time for any potential git sync to happen
tokio::time::sleep(Duration::from_secs(3)).await;
// 6. Verify bare repository was created (announcement was accepted)
let repo_path = archive_relay
.git_data_path()
.join(format!("{}/{}.git", npub, identifier));
assert!(
repo_path.exists(),
"Bare repository should be created for archive announcement"
);
// 7. Verify git data was NOT synced (no state events to trigger purgatory sync)
// Check that the commit does NOT exist in the archive relay's repo
let output = tokio::process::Command::new("git")
.args(["cat-file", "-t", &commit_hash])
.current_dir(&repo_path)
.output()
.await;
let commit_exists = output.map(|o| o.status.success()).unwrap_or(false);
assert!(
!commit_exists,
"Git data should NOT be synced without state events (security: validates against Nostr state)"
);
// Cleanup
source_client.disconnect().await;
archive_relay.stop().await;
source_relay.stop().await;
}
|