mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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:
+4
-2
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+28
-39
@@ -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(),
|
|
||||||
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 mismatches = state::ref_mismatches(&expected, &fetched_refs);
|
|
||||||
|
|
||||||
checks.push(ProbeCheck {
|
|
||||||
name: ProbeCheckName::GitRefsMatchState.as_str(),
|
|
||||||
passed: mismatches.is_empty(),
|
|
||||||
skipped: false,
|
|
||||||
duration_ms: 0,
|
|
||||||
detail: None,
|
|
||||||
error: if mismatches.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(mismatches.join("; "))
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
Err(error) => Err(format!(
|
||||||
|
"failed to fetch repository authority and state: {error}"
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let error = match expected {
|
||||||
|
Ok(expected) => {
|
||||||
|
let mismatches = state::ref_mismatches(&expected, &fetched_refs);
|
||||||
|
(!mismatches.is_empty()).then(|| mismatches.join("; "))
|
||||||
|
}
|
||||||
|
Err(error) => Some(error),
|
||||||
|
};
|
||||||
|
checks.push(ProbeCheck {
|
||||||
|
name: ProbeCheckName::GitRefsMatchState.as_str(),
|
||||||
|
passed: error.is_none(),
|
||||||
|
skipped: false,
|
||||||
|
duration_ms: 0,
|
||||||
|
detail: None,
|
||||||
|
error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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