Files
ngit-grasp/tests/event_integrity_command.rs
DanConwayDev f63400671f feat(maintenance): verify and purge persisted forged events
The v3.0.9 security guidance must provide an executable repair path for data
accepted before sync verified payloads independently. Add an offline
event-integrity-check command with JSON-lines reporting and explicit --execute.
Scan the main, tombstone, holding and replacement-history databases, plus
purgatory and rejected-event checkpoints, without skipping timestamp ties or
expired LMDB payloads.

Purge only invalid payloads and their linked internal metadata. Reuse deletion
lifecycle request removal, but never create a NIP-09 tombstone for an untrusted
claimed ID or follow forged tags into Git/cascade deletion. Preserve valid
copies in other stores and pending placeholders. Reset payload-free negative
caches because their decisions cannot be verified and could block recovery.

The relay must be stopped for both modes; this deliberately avoids adding a
live repair race or startup option. Checkpoints are parsed before mutation and
rewritten atomically. Partial runs are retryable. Git side-effect reconstruction
and policy authorization of correctly signed events remain separate concerns.
Document backup, scan, purge and rescan commands alongside this change in
the Unreleased changelog, including the existing Git integrity repair command.

Validation: 1,010 library tests passed during development; the final five audit
regressions, seven binary tests, and two real-command integration tests pass.
All-target workspace Clippy with warnings denied, formatting and diff checks
pass. Coverage includes forgery recovery, lifecycle metadata removal, expired
payloads, timestamp ties, valid-copy preservation, checkpoint cache repair,
missing paths and malformed-checkpoint refusal.

Assisted-by: GPT-6
2026-10-27 08:49:40 +00:00

105 lines
3.3 KiB
Rust

//! Exercise the actual offline command against persisted forged payloads.
use nostr_lmdb::NostrLmdb;
use nostr_sdk::prelude::*;
use tokio::process::Command;
async fn run(
relay: &std::path::Path,
git: &std::path::Path,
execute: bool,
) -> std::process::Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_ngit-grasp"));
command
.kill_on_drop(true)
.arg("event-integrity-check")
.arg("--relay-data-path")
.arg(relay)
.arg("--git-data-path")
.arg(git);
if execute {
command.arg("--execute");
}
tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
.await
.unwrap()
.unwrap()
}
#[tokio::test]
async fn command_reports_purges_and_allows_genuine_recovery() {
let root = tempfile::tempdir().unwrap();
let relay = root.path().join("relay");
let git = root.path().join("git");
std::fs::create_dir_all(&relay).unwrap();
std::fs::create_dir_all(&git).unwrap();
let genuine = EventBuilder::new(Kind::TextNote, "genuine")
.finalize(&Keys::generate())
.unwrap();
let mut forged = genuine.clone();
forged.content = "forged".into();
{
let db = NostrLmdb::builder(&relay)
.process_nip09(false)
.process_nip62(false)
.build()
.await
.unwrap();
db.save_event(&forged).await.unwrap();
}
let dry = run(&relay, &git, false).await;
assert!(!dry.status.success());
let lines: Vec<serde_json::Value> = String::from_utf8(dry.stdout)
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(lines[0]["claimed_id"], genuine.id.to_hex());
assert_eq!(lines.last().unwrap()["stores"]["main"]["purged"], 0);
let checkpoint = git.join("purgatory-state.json");
std::fs::write(&checkpoint, "not a checkpoint").unwrap();
assert!(!run(&relay, &git, true).await.status.success());
{
let db = NostrLmdb::builder(&relay)
.process_nip09(false)
.process_nip62(false)
.build()
.await
.unwrap();
assert_eq!(
db.event_by_id(&genuine.id).await.unwrap(),
Some(forged.clone())
);
}
std::fs::remove_file(&checkpoint).unwrap();
let repaired = run(&relay, &git, true).await;
assert!(
repaired.status.success(),
"{}",
String::from_utf8_lossy(&repaired.stderr)
);
let clean = run(&relay, &git, false).await;
assert!(
clean.status.success(),
"{}",
String::from_utf8_lossy(&clean.stderr)
);
let db = NostrLmdb::builder(&relay)
.process_nip09(false)
.process_nip62(false)
.build()
.await
.unwrap();
assert!(db.event_by_id(&genuine.id).await.unwrap().is_none());
db.save_event(&genuine).await.unwrap();
assert_eq!(db.event_by_id(&genuine.id).await.unwrap(), Some(genuine));
}
#[tokio::test]
async fn command_rejects_nonexistent_database_without_creating_it() {
let root = tempfile::tempdir().unwrap();
let missing = root.path().join("missing");
let output = run(&missing, root.path(), true).await;
assert!(!output.status.success());
assert!(!missing.exists());
}