fix(audit): scope state to the selected repository's maintainers

Fetching kind 30618 by identifier alone mixed independently owned repositories. A newer unrelated state could supply the wrong expected refs even though both repositories were valid on the relay.

Fetch announcements alongside state, select each author's NIP-01-preferred announcement, and resolve current publishers from the selected coordinate. Support reciprocal legacy/indexed roles, transitive confirmation, departures and explicit lead forwarding; reject unresolved lead paths. Verify and scope events before selecting the newest authorized state. Keep the audit crate independent of ngit-grasp.

Authority uses the probed relay's visible announcements. This does not change server authorization, add cross-relay discovery or provide an atomic Git/Nostr snapshot.

Validation: 95 grasp-audit tests pass (5 ignored), including membership and ordering regressions. The local-relay integration target passes all 55 tests, with an end-to-end same-identifier collision and injected stale refs. Workspace all-target Clippy and formatting pass.

Assisted-by: GPT-6
This commit is contained in:
DanConwayDev
2026-09-17 16:32:16 +00:00
parent 336b16d228
commit 9fa726bbe1
5 changed files with 514 additions and 44 deletions
+4 -2
View File
@@ -9,8 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Report unexpected branches and tags in audit probes, including refs left - Scope read-only audit state selection to the selected repository's confirmed
behind when the winning repository state is empty. maintainers, excluding unrelated repositories with the same identifier.
Audit probes now also report unexpected branches and tags, including refs
left behind when the winning state is empty.
- Select one complete repository state in the read-only audit probe, using - Select one complete repository state in the read-only audit probe, using
the lower event ID for same-second ties. Older states no longer contribute the lower event ID for same-second ties. Older states no longer contribute
+6
View File
@@ -69,6 +69,12 @@ bodies. They are deliberately read-only and cannot be combined with
| `git_fetch_refs` | Git HTTP info/refs endpoint responds | | `git_fetch_refs` | Git HTTP info/refs endpoint responds |
| `git_refs_match_state` | Git refs match the newest kind:30618 state snapshot (lowest event ID breaks timestamp ties) | | `git_refs_match_state` | Git refs match the newest kind:30618 state snapshot (lowest event ID breaks timestamp ties) |
The read-only ref check resolves the selected announcement's confirmed maintainer
component before choosing state. Same-identifier repositories owned by unrelated
authors cannot supply its expected refs. Legacy reciprocal listings and indexed
roles are supported; missing or conflicting lead paths fail the check. The probe
uses the announcements visible on the queried relay at the time of the check.
Both read-only and write probes compare the complete branch/tag set: missing, Both read-only and write probes compare the complete branch/tag set: missing,
changed, and unexpected refs fail. HEAD, Nostr refs, and peeled annotated-tag changed, and unexpected refs fail. HEAD, Nostr refs, and peeled annotated-tag
entries are excluded from that set. entries are excluded from that set.
+29 -40
View File
@@ -1472,53 +1472,42 @@ pub async fn run_probe_with_options(
Some(body) => { Some(body) => {
let fetched_refs = parse_refs(&body); let fetched_refs = parse_refs(&body);
// Fetch all state events for this repo_id from the relay. // Resolve authority and state from the same repository-scoped fetch.
// The relay only serves state events authorized by a selected let filter = Filter::new()
// coordinate's resolved reciprocal component. .kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
let state_filter = Filter::new().kind(Kind::RepoState).custom_tag( .custom_tag(SingleLetterTag::LOWERCASE_D, ann_id.clone());
nostr_sdk::prelude::SingleLetterTag::LOWERCASE_D, let expected = match client
ann_id.clone(),
);
let state_events = client
.client() .client()
.fetch_events(state_filter) .fetch_events(filter)
.timeout( .timeout(
deadline deadline
.saturating_duration_since(Instant::now()) .saturating_duration_since(Instant::now())
.min(Duration::from_secs(5)), .min(Duration::from_secs(5)),
) )
.await .await
.unwrap_or_default(); {
Ok(events) => {
if state_events.is_empty() { state::scoped_state_refs(events.iter(), ev.pubkey, &ann_id)
checks.push(ProbeCheck { }
name: ProbeCheckName::GitRefsMatchState.as_str(), Err(error) => Err(format!(
passed: false, "failed to fetch repository authority and state: {error}"
skipped: false, )),
duration_ms: 0, };
detail: None, let error = match expected {
error: Some( Ok(expected) => {
"no kind:30618 state events found for this repo".to_string(), let mismatches = state::ref_mismatches(&expected, &fetched_refs);
), (!mismatches.is_empty()).then(|| mismatches.join("; "))
}); }
} else { Err(error) => Some(error),
let expected = expected_state_refs(state_events.iter()); };
checks.push(ProbeCheck {
let mismatches = state::ref_mismatches(&expected, &fetched_refs); name: ProbeCheckName::GitRefsMatchState.as_str(),
passed: error.is_none(),
checks.push(ProbeCheck { skipped: false,
name: ProbeCheckName::GitRefsMatchState.as_str(), duration_ms: 0,
passed: mismatches.is_empty(), detail: None,
skipped: false, error,
duration_ms: 0, });
detail: None,
error: if mismatches.is_empty() {
None
} else {
Some(mismatches.join("; "))
},
});
}
} }
} }
} }
+360 -2
View File
@@ -1,6 +1,159 @@
//! Complete branch and tag comparison for audit probes. //! Read-side authority and ref comparison for the selected probe coordinate.
use std::collections::{BTreeMap, HashMap}; use nostr_sdk::prelude::*;
use std::collections::{BTreeMap, HashMap, HashSet};
fn preferred(left: &Event, right: &Event) -> std::cmp::Ordering {
left.created_at
.cmp(&right.created_at)
.then_with(|| right.id.cmp(&left.id))
}
struct Roles {
active_author: bool,
maintainers: HashSet<PublicKey>,
leads: HashSet<PublicKey>,
}
fn roles(event: &Event) -> Roles {
let mut result = Roles {
active_author: true,
maintainers: HashSet::new(),
leads: HashSet::new(),
};
let mut indexed = false;
let mut explicit_self = false;
for tag in event.tags.iter() {
let values = tag.as_slice();
if !matches!(values.first().map(String::as_str), Some("M" | "m" | "o")) {
continue;
}
indexed = true;
let Some(key) = values.get(1).and_then(|key| PublicKey::from_hex(key).ok()) else {
continue;
};
explicit_self |= key == event.pubkey;
let boundaries = &values[2..];
// Only a final numeric start (or an untimed role) is active.
// Ended, deferred and malformed roles grant no current authority.
let active = (boundaries.is_empty() || boundaries.len() % 2 == 1)
&& boundaries.iter().all(|value| value.parse::<u64>().is_ok());
if values[0] != "o" && active {
result.maintainers.insert(key);
if values[0] == "M" {
result.leads.insert(key);
}
}
}
if indexed {
result.active_author = !explicit_self || result.maintainers.contains(&event.pubkey);
} else if let Some(tag) = event
.tags
.iter()
.find(|tag| tag.as_slice()[0] == "maintainers")
{
result.maintainers.extend(
tag.as_slice()[1..]
.iter()
.filter_map(|key| PublicKey::from_hex(key).ok()),
);
}
result
}
/// Resolve current state publishers from the selected coordinate, not every
/// repository that happens to share its identifier.
fn state_authors(
announcements: &HashMap<PublicKey, &Event>,
selected: PublicKey,
) -> Result<HashSet<PublicKey>, String> {
let graph: HashMap<_, _> = announcements
.iter()
.map(|(key, event)| (*key, roles(event)))
.collect();
let mut root = selected;
let mut followed_lead = false;
let mut visited = HashSet::new();
loop {
if !visited.insert(root) {
return Err("repository authority has a lead cycle".into());
}
let node = graph
.get(&root)
.ok_or("repository authority announcement is missing")?;
match node.leads.len() {
0 if !followed_lead && node.active_author => break,
0 => {
return Err(
"repository authority has an inactive author or incomplete lead path".into(),
)
}
1 => {
let lead = *node.leads.iter().next().expect("one lead");
if lead == root {
break;
}
root = lead;
followed_lead = true;
}
_ => return Err("repository authority has ambiguous active leads".into()),
}
}
let mut confirmed = HashSet::from([root]);
loop {
let mut additions = HashSet::new();
for member in &confirmed {
for candidate in &graph[member].maintainers {
if confirmed.contains(candidate) {
continue;
}
if let Some(node) = graph.get(candidate) {
if node.active_author && !node.maintainers.is_disjoint(&confirmed) {
additions.insert(*candidate);
}
}
}
}
if additions.is_empty() {
return Ok(confirmed);
}
confirmed.extend(additions);
}
}
pub(super) fn scoped_state_refs<'a>(
events: impl IntoIterator<Item = &'a Event>,
selected: PublicKey,
identifier: &str,
) -> Result<HashMap<String, String>, String> {
let events: Vec<_> = events
.into_iter()
.filter(|event| {
matches!(event.kind, Kind::GitRepoAnnouncement | Kind::RepoState)
&& event.tags.identifier().as_deref() == Some(identifier)
&& event.verify().is_ok()
})
.collect();
let mut announcements: HashMap<PublicKey, &Event> = HashMap::new();
for event in events
.iter()
.copied()
.filter(|event| event.kind == Kind::GitRepoAnnouncement)
{
let current = announcements.entry(event.pubkey).or_insert(event);
if preferred(event, current).is_gt() {
*current = event;
}
}
let authors = state_authors(&announcements, selected)?;
let winner = events
.into_iter()
.filter(|event| event.kind == Kind::RepoState && authors.contains(&event.pubkey))
.max_by(|left, right| preferred(left, right))
.ok_or("no authorized kind:30618 state events found for this repository")?;
Ok(super::expected_state_refs([winner]))
}
/// Compare only branches and unpeeled tags. HEAD, protocol capabilities, /// Compare only branches and unpeeled tags. HEAD, protocol capabilities,
/// peeled annotated tags and refs/nostr are not repository-state refs. /// peeled annotated tags and refs/nostr are not repository-state refs.
@@ -40,6 +193,211 @@ pub(super) fn ref_mismatches(
mod tests { mod tests {
use super::*; use super::*;
fn announcement(keys: &Keys, tags: Vec<Tag>, time: u64) -> Event {
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(std::iter::once(Tag::identifier("repo")).chain(tags))
.custom_created_at(Timestamp::from_secs(time))
.finalize(keys)
.unwrap()
}
fn role(letter: &str, keys: &Keys, history: &[&str]) -> Tag {
Tag::custom(
letter,
std::iter::once(keys.public_key().to_hex())
.chain(history.iter().map(|value| value.to_string())),
)
}
fn state(keys: &Keys, time: u64, hash: &str) -> Event {
EventBuilder::new(Kind::RepoState, "")
.tags([
Tag::identifier("repo"),
Tag::custom("refs/heads/main", [hash]),
])
.custom_created_at(Timestamp::from_secs(time))
.finalize(keys)
.unwrap()
}
fn selected(events: &[Event], owner: &Keys) -> Result<String, String> {
scoped_state_refs(events, owner.public_key(), "repo")
.map(|refs| refs["refs/heads/main"].clone())
}
#[test]
fn unrelated_and_unaccepted_authors_cannot_supply_state() {
let owner = Keys::generate();
let unrelated = Keys::generate();
let invited = Keys::generate();
let events = vec![
announcement(&owner, vec![role("m", &invited, &[])], 1),
announcement(&unrelated, vec![], 1),
announcement(&invited, vec![], 1),
state(&owner, 2, "owner"),
state(&unrelated, 3, "unrelated"),
state(&invited, 4, "unaccepted"),
];
assert_eq!(selected(&events, &owner).unwrap(), "owner");
}
#[test]
fn reciprocal_membership_reaches_transitive_maintainers() {
let owner = Keys::generate();
let member = Keys::generate();
let third = Keys::generate();
for legacy in [false, true] {
let tags = |keys: &[&Keys]| {
if legacy {
vec![Tag::custom(
"maintainers",
keys.iter().map(|key| key.public_key().to_hex()),
)]
} else {
keys.iter().map(|key| role("m", key, &[])).collect()
}
};
let events = vec![
announcement(&owner, tags(&[&member]), 1),
announcement(&member, tags(&[&owner, &third]), 1),
announcement(&third, tags(&[&member]), 1),
state(&owner, 2, "owner"),
state(&third, 3, "third"),
];
assert_eq!(selected(&events, &owner).unwrap(), "third");
}
}
#[test]
fn indexed_roles_override_legacy_and_exclude_inactive_or_moderator_authors() {
let owner = Keys::generate();
let member = Keys::generate();
for self_role in [
role("m", &member, &["1", "2"]),
role("m", &member, &["1", "defer"]),
role("m", &member, &["bad"]),
role("o", &member, &[]),
] {
let events = vec![
announcement(&owner, vec![role("m", &member, &[])], 1),
announcement(&member, vec![role("m", &owner, &[]), self_role], 1),
state(&owner, 2, "owner"),
state(&member, 3, "inactive"),
];
assert_eq!(selected(&events, &owner).unwrap(), "owner");
}
let events = vec![
announcement(
&owner,
vec![
role("o", &member, &[]),
Tag::custom("maintainers", [member.public_key().to_hex()]),
],
1,
),
announcement(&member, vec![role("m", &owner, &[])], 1),
state(&owner, 2, "owner"),
state(&member, 3, "moderator"),
];
assert_eq!(selected(&events, &owner).unwrap(), "owner");
}
#[test]
fn lead_redirect_does_not_restore_removed_author() {
let lead = Keys::generate();
let former = Keys::generate();
let events = vec![
announcement(&lead, vec![role("M", &lead, &[])], 1),
announcement(
&former,
vec![role("M", &lead, &[]), role("m", &former, &["1", "2"])],
1,
),
state(&lead, 2, "lead"),
state(&former, 3, "removed"),
];
assert_eq!(selected(&events, &former).unwrap(), "lead");
}
#[test]
fn missing_ambiguous_and_cyclic_lead_paths_fail_closed() {
let owner = Keys::generate();
let lead = Keys::generate();
let mut events = vec![
announcement(&owner, vec![role("M", &lead, &[])], 1),
state(&owner, 2, "owner"),
];
assert!(selected(&events, &owner).unwrap_err().contains("missing"));
events.push(announcement(&lead, vec![], 1));
assert!(selected(&events, &owner)
.unwrap_err()
.contains("incomplete"));
events.pop();
events.push(announcement(&lead, vec![role("M", &owner, &[])], 1));
assert!(selected(&events, &owner).unwrap_err().contains("cycle"));
events[0] = announcement(
&owner,
vec![role("M", &owner, &[]), role("M", &lead, &[])],
1,
);
assert!(selected(&events, &owner).unwrap_err().contains("ambiguous"));
}
#[test]
fn latest_announcement_controls_membership_including_same_second_ties() {
let owner = Keys::generate();
let member = Keys::generate();
let mut announcements = [
announcement(&owner, vec![], 1),
announcement(&owner, vec![role("m", &member, &[])], 1),
];
announcements.sort_by_key(|event| event.id);
let grants = roles(&announcements[0])
.maintainers
.contains(&member.public_key());
for reverse in [false, true] {
let mut events = announcements.to_vec();
if reverse {
events.reverse();
}
events.extend([
announcement(&member, vec![role("m", &owner, &[])], 1),
state(&owner, 2, "owner"),
state(&member, 3, "member"),
]);
assert_eq!(
selected(&events, &owner).unwrap(),
if grants { "member" } else { "owner" }
);
events.push(announcement(&owner, vec![], 4));
assert_eq!(selected(&events, &owner).unwrap(), "owner");
}
}
#[test]
fn unrelated_identifiers_and_invalid_signatures_are_ignored() {
let owner = Keys::generate();
let mut invalid = state(&owner, 100, "invalid");
invalid.content = "tampered".into();
let other = EventBuilder::new(Kind::RepoState, "")
.tags([
Tag::identifier("other"),
Tag::custom("refs/heads/main", ["other"]),
])
.custom_created_at(Timestamp::from_secs(100))
.finalize(&owner)
.unwrap();
let events = vec![
announcement(&owner, vec![], 1),
state(&owner, 2, "owner"),
invalid,
other,
];
assert_eq!(selected(&events, &owner).unwrap(), "owner");
assert!(scoped_state_refs(&events[..1], owner.public_key(), "repo").is_err());
assert!(scoped_state_refs(&events[1..], owner.public_key(), "repo").is_err());
}
#[test] #[test]
fn ref_comparison_requires_complete_branch_and_tag_set() { fn ref_comparison_requires_complete_branch_and_tag_set() {
let expected: HashMap<String, String> = HashMap::from([ let expected: HashMap<String, String> = HashMap::from([
+115
View File
@@ -0,0 +1,115 @@
mod common;
use common::{publish_served_audit_repo_with_state, wait_for_event_served, TestRelay};
use grasp_audit::{probe::run_probe, AuditClient, AuditConfig, DETERMINISTIC_COMMIT_HASH};
use nostr_sdk::prelude::*;
use std::time::Duration;
#[tokio::test]
async fn probe_scopes_same_identifier_states_and_reports_extra_refs() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::shared())
.await
.unwrap();
let (announcement, identifier, initial_state) =
publish_served_audit_repo_with_state(&client, "probe-scope").await;
let owner_path = relay.git_data_path().join(
ngit_grasp::nostr::events::RepositoryAnnouncement::from_event(announcement.clone())
.unwrap()
.repo_path(),
);
let unrelated = Keys::generate();
let foreign_announcement =
common::create_repo_announcement(&unrelated, &[&relay.domain()], &identifier);
// Ensure the probe selects the owner's announcement, while the unrelated
// repository supplies the newest state for this identifier.
let foreign_announcement = EventBuilder::new(foreign_announcement.kind, "")
.tags(foreign_announcement.tags.iter().cloned())
.custom_created_at(Timestamp::from_secs(announcement.created_at.as_secs() - 1))
.finalize(&unrelated)
.unwrap();
let foreign_path = relay.git_data_path().join(
ngit_grasp::nostr::events::RepositoryAnnouncement::from_event(foreign_announcement.clone())
.unwrap()
.repo_path(),
);
std::fs::create_dir_all(foreign_path.parent().unwrap()).unwrap();
let cloned = grasp_audit::git_command()
.args(["clone", "--bare"])
.arg(&owner_path)
.arg(&foreign_path)
.output()
.unwrap();
assert!(
cloned.status.success(),
"{}",
String::from_utf8_lossy(&cloned.stderr)
);
client
.send_event(foreign_announcement.clone())
.await
.unwrap();
let foreign_state = EventBuilder::new(Kind::RepoState, "")
.tags([
Tag::identifier(&identifier),
Tag::custom("refs/heads/foreign", [DETERMINISTIC_COMMIT_HASH]),
Tag::custom("HEAD", ["ref: refs/heads/foreign"]),
])
.custom_created_at(Timestamp::from_secs(initial_state.created_at.as_secs() + 1))
.finalize(&unrelated)
.unwrap();
client.send_event(foreign_state.clone()).await.unwrap();
for event in [&foreign_announcement, &foreign_state, &initial_state] {
wait_for_event_served(relay.url(), &event.id, Duration::from_secs(10))
.await
.unwrap();
}
let report = run_probe(relay.url(), None, true, 10, 30).await;
let selection = report
.checks
.iter()
.find(|check| check.name == "serves_latest_announcement")
.unwrap();
assert_eq!(
selection.detail.as_deref(),
Some(
format!(
"{}/{}",
client.public_key().to_bech32().unwrap(),
identifier
)
.as_str()
)
);
let check = report
.checks
.iter()
.find(|check| check.name == "git_refs_match_state")
.unwrap();
assert!(check.passed, "{check:?}");
for name in ["refs/heads/stale", "refs/tags/stale"] {
let result = grasp_audit::git_command()
.arg("--git-dir")
.arg(&owner_path)
.args(["update-ref", name, DETERMINISTIC_COMMIT_HASH])
.output()
.unwrap();
assert!(result.status.success());
}
let report = run_probe(relay.url(), None, true, 10, 30).await;
let check = report
.checks
.iter()
.find(|check| check.name == "git_refs_match_state")
.unwrap();
assert!(!check.passed);
let error = check.error.as_deref().unwrap();
assert!(
error.contains("refs/heads/stale: unexpected ref"),
"{error}"
);
assert!(error.contains("refs/tags/stale: unexpected ref"), "{error}");
relay.stop().await;
}