mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
Merge #a54fbcf9: Fix audit repository scoping and stale Git ref detecti…
Fix audit repository scoping and stale Git ref detection nostr:nevent1qgsx2lyl2e4zvfadwcvkd9fkrcwczj7mf858hy85mwqclwgut8wpg2spz3mhxue69uhhyetvv9ujumn8d96zuer9wcq3yamnwvaz7tm8d96xummnw3ezucm0d5q3kamnwvaz7tmwva5hgtnyv9hxxmmwwashjer9wchxxmmdqqs22naulxs7eh7h7avmv8svxpw7f9r0z63ywe2v04a9ddmgpdh5rrcsx9r5q PR-Author: DanConwayDev's Agent nostr:npub1v47f74n2ycn66asev62nv8sas99akj0g0wg0fkup37u3ckwuzs4q7cwtp0 PR description: The read-only audit probe could choose state from an unrelated repository sharing the same identifier. It also accepted extra Git branches and tags, allowing stale refs to pass even when the selected state was empty. This PR: - Requires an exact branch/tag match in both read-only and write probes, excluding HEAD, Nostr refs and peeled tag entries. - Resolves confirmed state authors from the selected repository coordinate before choosing the newest state. It handles reciprocal legacy/indexed roles and lead forwarding; unresolved lead paths fail the check. - Keeps state ordering as newest timestamp, then lowest event ID. The two commits separate ref comparison from authority scoping. Authority is derived from announcements visible on the probed relay; this does not add cross-relay discovery or change server authorization. Validation: 95 audit tests passed (5 ignored), plus 55 tests in the local-relay integration target. The end-to-end regression hosts unrelated same-identifier repositories, then injects stale refs. Workspace all-target Clippy and formatting pass.
This commit is contained in:
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Scope read-only audit state selection to the selected repository's confirmed
|
||||||
|
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
|
||||||
removed branches or tags to the expected Git refs.
|
removed branches or tags to the expected Git refs.
|
||||||
|
|||||||
@@ -69,6 +69,16 @@ 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,
|
||||||
|
changed, and unexpected refs fail. HEAD, Nostr refs, and peeled annotated-tag
|
||||||
|
entries are excluded from that set.
|
||||||
|
|
||||||
**Additional checks with `--create-repo`:**
|
**Additional checks with `--create-repo`:**
|
||||||
|
|
||||||
| Check | What it verifies |
|
| Check | What it verifies |
|
||||||
|
|||||||
+32
-96
@@ -4,6 +4,8 @@
|
|||||||
//! human-readable or JSON format. It is designed to be fast and non-destructive
|
//! human-readable or JSON format. It is designed to be fast and non-destructive
|
||||||
//! when run in read-only mode.
|
//! when run in read-only mode.
|
||||||
|
|
||||||
|
mod state;
|
||||||
|
|
||||||
use crate::audit::AuditConfig;
|
use crate::audit::AuditConfig;
|
||||||
use crate::client::AuditClient;
|
use crate::client::AuditClient;
|
||||||
use crate::fixtures::{create_commit, init_local_repo, try_push};
|
use crate::fixtures::{create_commit, init_local_repo, try_push};
|
||||||
@@ -1224,42 +1226,8 @@ pub async fn run_probe_with_options(
|
|||||||
}
|
}
|
||||||
Some(body) => {
|
Some(body) => {
|
||||||
let fetched_refs = parse_refs(&body);
|
let fetched_refs = parse_refs(&body);
|
||||||
let mut mismatches: Vec<String> = Vec::new();
|
let expected = expected_state_refs(state_event_opt.iter());
|
||||||
|
let mismatches = state::ref_mismatches(&expected, &fetched_refs);
|
||||||
if let Some(ref state_ev) = state_event_opt {
|
|
||||||
for tag in state_ev.tags.iter() {
|
|
||||||
let kind_str = tag.kind().to_string();
|
|
||||||
// Only check refs/heads/* and refs/tags/*, skip HEAD and refs/nostr/*
|
|
||||||
if !kind_str.starts_with("refs/heads/")
|
|
||||||
&& !kind_str.starts_with("refs/tags/")
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let expected_hash = match tag.content() {
|
|
||||||
Some(h) => h.to_string(),
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let found = fetched_refs.iter().find(|(r, _)| r == &kind_str);
|
|
||||||
match found {
|
|
||||||
Some((_, actual_hash)) if actual_hash == &expected_hash => {}
|
|
||||||
Some((_, actual_hash)) => {
|
|
||||||
mismatches.push(format!(
|
|
||||||
"{}: expected {} got {}",
|
|
||||||
kind_str,
|
|
||||||
&expected_hash[..8.min(expected_hash.len())],
|
|
||||||
&actual_hash[..8.min(actual_hash.len())]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
mismatches.push(format!(
|
|
||||||
"{}: expected {} not found in refs",
|
|
||||||
kind_str,
|
|
||||||
&expected_hash[..8.min(expected_hash.len())]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checks.push(ProbeCheck {
|
checks.push(ProbeCheck {
|
||||||
name: ProbeCheckName::GitRefsMatchState.as_str(),
|
name: ProbeCheckName::GitRefsMatchState.as_str(),
|
||||||
@@ -1504,74 +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(),
|
|
||||||
passed: false,
|
|
||||||
skipped: false,
|
|
||||||
duration_ms: 0,
|
|
||||||
detail: None,
|
|
||||||
error: Some(
|
|
||||||
"no kind:30618 state events found for this repo".to_string(),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let expected = expected_state_refs(state_events.iter());
|
|
||||||
|
|
||||||
let mut mismatches: Vec<String> = Vec::new();
|
|
||||||
for (refname, expected_hash) in &expected {
|
|
||||||
let found = fetched_refs.iter().find(|(r, _)| r == refname);
|
|
||||||
match found {
|
|
||||||
Some((_, actual_hash)) if actual_hash == expected_hash => {}
|
|
||||||
Some((_, actual_hash)) => {
|
|
||||||
mismatches.push(format!(
|
|
||||||
"{}: expected {} got {}",
|
|
||||||
refname,
|
|
||||||
&expected_hash[..8.min(expected_hash.len())],
|
|
||||||
&actual_hash[..8.min(actual_hash.len())]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
mismatches.push(format!(
|
|
||||||
"{}: expected {} not found in refs",
|
|
||||||
refname,
|
|
||||||
&expected_hash[..8.min(expected_hash.len())]
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Err(error) => Err(format!(
|
||||||
checks.push(ProbeCheck {
|
"failed to fetch repository authority and state: {error}"
|
||||||
name: ProbeCheckName::GitRefsMatchState.as_str(),
|
)),
|
||||||
passed: mismatches.is_empty(),
|
};
|
||||||
skipped: false,
|
let error = match expected {
|
||||||
duration_ms: 0,
|
Ok(expected) => {
|
||||||
detail: None,
|
let mismatches = state::ref_mismatches(&expected, &fetched_refs);
|
||||||
error: if mismatches.is_empty() {
|
(!mismatches.is_empty()).then(|| mismatches.join("; "))
|
||||||
None
|
}
|
||||||
} else {
|
Err(error) => Some(error),
|
||||||
Some(mismatches.join("; "))
|
};
|
||||||
},
|
checks.push(ProbeCheck {
|
||||||
});
|
name: ProbeCheckName::GitRefsMatchState.as_str(),
|
||||||
}
|
passed: error.is_none(),
|
||||||
|
skipped: false,
|
||||||
|
duration_ms: 0,
|
||||||
|
detail: None,
|
||||||
|
error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,434 @@
|
|||||||
|
//! Read-side authority and ref comparison for the selected probe coordinate.
|
||||||
|
|
||||||
|
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,
|
||||||
|
/// peeled annotated tags and refs/nostr are not repository-state refs.
|
||||||
|
pub(super) fn ref_mismatches(
|
||||||
|
expected: &HashMap<String, String>,
|
||||||
|
actual: &[(String, String)],
|
||||||
|
) -> Vec<String> {
|
||||||
|
let actual: BTreeMap<_, _> = actual
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, _)| {
|
||||||
|
(name.starts_with("refs/heads/") || name.starts_with("refs/tags/"))
|
||||||
|
&& !name.ends_with("^{}")
|
||||||
|
})
|
||||||
|
.map(|(name, hash)| (name.as_str(), hash.as_str()))
|
||||||
|
.collect();
|
||||||
|
let expected: BTreeMap<_, _> = expected
|
||||||
|
.iter()
|
||||||
|
.map(|(name, hash)| (name.as_str(), hash.as_str()))
|
||||||
|
.collect();
|
||||||
|
let mut mismatches = Vec::new();
|
||||||
|
for (name, hash) in &expected {
|
||||||
|
match actual.get(name) {
|
||||||
|
Some(actual) if actual == hash => {}
|
||||||
|
Some(actual) => mismatches.push(format!("{name}: expected {hash} got {actual}")),
|
||||||
|
None => mismatches.push(format!("{name}: expected {hash} not found in refs")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name in actual.keys() {
|
||||||
|
if !expected.contains_key(name) {
|
||||||
|
mismatches.push(format!("{name}: unexpected ref"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mismatches
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
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]
|
||||||
|
fn ref_comparison_requires_complete_branch_and_tag_set() {
|
||||||
|
let expected: HashMap<String, String> = HashMap::from([
|
||||||
|
("refs/heads/main".into(), "abc".into()),
|
||||||
|
("refs/tags/v1".into(), "def".into()),
|
||||||
|
]);
|
||||||
|
let mut actual: Vec<_> = expected
|
||||||
|
.iter()
|
||||||
|
.map(|(name, hash)| (name.clone(), hash.clone()))
|
||||||
|
.collect();
|
||||||
|
actual.extend([
|
||||||
|
("HEAD".into(), "abc".into()),
|
||||||
|
("refs/nostr/test".into(), "abc".into()),
|
||||||
|
("refs/tags/v1^{}".into(), "abc".into()),
|
||||||
|
]);
|
||||||
|
assert!(ref_mismatches(&expected, &actual).is_empty());
|
||||||
|
actual.push(("refs/heads/stale".into(), "abc".into()));
|
||||||
|
actual.push(("refs/tags/stale".into(), "def".into()));
|
||||||
|
assert_eq!(
|
||||||
|
ref_mismatches(&expected, &actual),
|
||||||
|
[
|
||||||
|
"refs/heads/stale: unexpected ref",
|
||||||
|
"refs/tags/stale: unexpected ref",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(ref_mismatches(&HashMap::new(), &actual).len(), 4);
|
||||||
|
assert!(ref_mismatches(&HashMap::new(), &[]).is_empty());
|
||||||
|
assert_eq!(ref_mismatches(&expected, &[]).len(), 2);
|
||||||
|
actual[0].1 = "wrong".into();
|
||||||
|
assert!(ref_mismatches(&expected, &actual)
|
||||||
|
.iter()
|
||||||
|
.any(|error| error.contains("got wrong")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user