mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
fix(auth): root state validity at active lead
The v3 role-tag work filtered invited and ended listed maintainers, but every announcement author still seeded a repository view. A removed forwarding maintainer or an author with an ended self-role could therefore keep publishing authoritative kind 30618 state through their own coordinate. Parity-only role parsing could also treat malformed histories as active. Validate numeric role boundaries and the final-end-only defer sentinel, resolve each selected coordinate through one valid active M path to a terminal self-M, and only then seed the reciprocal membership fixpoint. Legacy and explicitly leadless views continue to root at the selected author while that author remains active; incomplete, ambiguous, and cyclic explicit paths fail closed. This changes current state and maintainer-scoped authorization only. Historical event authorization and membership mutation workflows remain outside the relay-side patch. Validated with cargo test --lib (902 tests), cargo test --test state_authorization (53 tests), and cargo clippy --tests -- -D warnings.
This commit is contained in:
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Correct kind `30618` repository-state authority for a view explicitly
|
||||||
|
selected through a former maintainer's coordinate. Previously, only that
|
||||||
|
selected view could continue accepting state signed by the former
|
||||||
|
maintainer after the lead stopped assigning them; this did not restore the
|
||||||
|
former maintainer's authority over views selected through the lead or any
|
||||||
|
other maintainer. Selected views now resolve authority through valid active
|
||||||
|
`M` records to the terminal maintainer. Ended selected authors, malformed
|
||||||
|
role histories, missing or ambiguous lead targets, and lead cycles fail
|
||||||
|
closed.
|
||||||
|
|
||||||
## [3.0.0] - 2026-08-20
|
## [3.0.0] - 2026-08-20
|
||||||
|
|
||||||
### Summary
|
### Summary
|
||||||
|
|||||||
@@ -296,16 +296,18 @@ pub struct RepositoryState { ... }
|
|||||||
Announcements carry maintainer listings in two formats:
|
Announcements carry maintainer listings in two formats:
|
||||||
|
|
||||||
- **Indexed role tags** (`M` lead, `m` co-maintainer). A tag may record
|
- **Indexed role tags** (`M` lead, `m` co-maintainer). A tag may record
|
||||||
history as alternating start/end timestamps; it is currently active when
|
history as alternating numeric start/end timestamps; `defer` is valid only
|
||||||
it has fewer than four elements or an odd number of elements. Ended
|
as the final end boundary. It is currently active when it has no boundaries
|
||||||
entries are ignored entirely - the history is only used to conclude a
|
or its final valid boundary is a start. Ended, deferred, and malformed
|
||||||
role has *ended*, never to grant authority for a past period. The lead /
|
entries grant no authority - the history is only used to conclude a role
|
||||||
co-maintainer distinction carries no meaning for this service. A pubkey
|
has *ended*, never to grant authority for a past period. Both `M` and `m`
|
||||||
may appear in multiple role tags: one of each letter records a role
|
grant maintainer authority, while active `M` records also form the lead
|
||||||
transition, and out-of-spec duplicates under the same letter are
|
path that roots the selected repository view. A pubkey may appear in
|
||||||
tolerated. Histories are consolidated - the pubkey is a maintainer while
|
multiple role tags: one of each letter records a role transition, and
|
||||||
any `M`/`m` entry is active. An author who appears in no role tag is
|
out-of-spec duplicates under the same letter are tolerated. Histories are
|
||||||
implicitly a maintainer for the repository's entire history.
|
consolidated - the pubkey is a maintainer while any valid `M`/`m` entry is
|
||||||
|
active. An author who appears in no role tag is implicitly a maintainer for
|
||||||
|
the repository's entire history.
|
||||||
- **Deprecated `maintainers` tag** (fallback). Ignored when `M`/`m` tags
|
- **Deprecated `maintainers` tag** (fallback). Ignored when `M`/`m` tags
|
||||||
are present. Without any listing tags the author is the sole maintainer.
|
are present. Without any listing tags the author is the sole maintainer.
|
||||||
A `u` (subordinate fork) tag has no effect on maintainership.
|
A `u` (subordinate fork) tag has no effect on maintainership.
|
||||||
@@ -331,9 +333,12 @@ Authorization follows a reciprocal membership rule, computed per owner by
|
|||||||
author: an ended `M`/`m` self-entry means the pubkey has left, which
|
author: an ended `M`/`m` self-entry means the pubkey has left, which
|
||||||
takes precedence over assignments in other announcements. An author who
|
takes precedence over assignments in other announcements. An author who
|
||||||
appears in no role tag implicitly asserts maintainership.
|
appears in no role tag implicitly asserts maintainership.
|
||||||
- The owner is always a confirmed maintainer of their own repository:
|
- A valid active `M` path is followed to a terminal self-`M` before the
|
||||||
announcing a repository in their namespace is what creates it on this
|
reciprocal fixpoint is seeded. Without an active `M`, the selected author
|
||||||
service.
|
roots a legacy or deliberately leadless view while their own role is active.
|
||||||
|
Missing, ambiguous, and cyclic explicit paths grant no state authority, and
|
||||||
|
a former maintainer's forwarding coordinate does not restore that signer to
|
||||||
|
the confirmed set.
|
||||||
|
|
||||||
#### [`policy/state.rs`](src/nostr/policy/state.rs) - State Event Authorization
|
#### [`policy/state.rs`](src/nostr/policy/state.rs) - State Event Authorization
|
||||||
|
|
||||||
|
|||||||
@@ -215,9 +215,11 @@ NIP-34 maintainers model refined in nips commit `781590b`).
|
|||||||
until its own announcement for the same identifier lists back an existing
|
until its own announcement for the same identifier lists back an existing
|
||||||
confirmed maintainer.
|
confirmed maintainer.
|
||||||
- Only confirmed maintainers publish authoritative repository state.
|
- Only confirmed maintainers publish authoritative repository state.
|
||||||
- The owner is always a confirmed maintainer of their own repository:
|
- Authority is rooted at the terminal lead reached through valid active `M`
|
||||||
announcing a repository in their namespace is what creates it on this
|
records. Without an active `M`, the selected author roots a legacy or
|
||||||
service.
|
deliberately leadless view only while their own role is active. Once an
|
||||||
|
explicit path is followed, a missing target, multiple active targets, or a
|
||||||
|
cycle grants no repository-state authority.
|
||||||
|
|
||||||
### Implementation choices
|
### Implementation choices
|
||||||
|
|
||||||
@@ -240,15 +242,16 @@ NIP-34 maintainers model refined in nips commit `781590b`).
|
|||||||
- `M` (lead) and `m` (co-maintainer) tags are the primary maintainer
|
- `M` (lead) and `m` (co-maintainer) tags are the primary maintainer
|
||||||
listing (per the model clarified in nips commit `986edd1`); when present
|
listing (per the model clarified in nips commit `986edd1`); when present
|
||||||
the deprecated `maintainers` tag is ignored per NIP-34 (and parsed as
|
the deprecated `maintainers` tag is ignored per NIP-34 (and parsed as
|
||||||
empty). The role distinction carries no meaning for this service: both
|
empty). Both grant equal maintainer authority, while active `M` records
|
||||||
collapse into one maintainer set.
|
additionally define the lead path used to root that authority.
|
||||||
- Role tags may record history as alternating start/end timestamps; a tag
|
- Role tags may record history as alternating numeric start/end timestamps;
|
||||||
is currently active when it has fewer than four elements or an odd number
|
`defer` is valid only as the final end boundary. A valid tag is currently
|
||||||
of elements. Ended entries are ignored entirely: role history is only
|
active when it has no boundaries or its final boundary is a start. Ended,
|
||||||
used to conclude that a pubkey is *no longer* a maintainer, never to
|
deferred, and malformed entries grant no authority: role history is only
|
||||||
grant time-scoped retroactive authority over historic events. The
|
used to conclude that a pubkey is *no longer* a maintainer, never to grant
|
||||||
NIP's owner-first precedence for conflicting past-role records is
|
time-scoped retroactive authority over historic events. The NIP's
|
||||||
therefore unused.
|
owner-first precedence for conflicting past-role records is therefore
|
||||||
|
unused.
|
||||||
- A pubkey may appear in multiple role tags: one of each letter records a
|
- A pubkey may appear in multiple role tags: one of each letter records a
|
||||||
role transition per the NIP, and out-of-spec duplicates under the same
|
role transition per the NIP, and out-of-spec duplicates under the same
|
||||||
letter are consolidated rather than rejected - rejecting them would drop
|
letter are consolidated rather than rejected - rejecting them would drop
|
||||||
@@ -284,4 +287,3 @@ the `M`/`m` assignments they might carry (the NIP says role combinations
|
|||||||
beyond self-plus-lead SHOULD be avoided unless the author is `M`), and
|
beyond self-plus-lead SHOULD be avoided unless the author is `M`), and
|
||||||
moderator membership gets no reciprocal-confirmation treatment. Both only
|
moderator membership gets no reciprocal-confirmation treatment. Both only
|
||||||
matter if status-event authority is ever enforced.
|
matter if status-event authority is ever enforced.
|
||||||
|
|
||||||
|
|||||||
+159
-6
@@ -316,7 +316,8 @@ pub fn pubkey_authorised_for_repo_owners(
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct RepoMembership {
|
pub struct RepoMembership {
|
||||||
/// Pubkeys whose repository state events are authoritative for this
|
/// Pubkeys whose repository state events are authoritative for this
|
||||||
/// owner's repository: the owner plus every confirmed maintainer.
|
/// owner's selected repository view: the resolved lead or selected
|
||||||
|
/// leadless/legacy root plus every confirmed maintainer.
|
||||||
pub state_maintainers: HashSet<String>,
|
pub state_maintainers: HashSet<String>,
|
||||||
/// Pubkeys currently listed as maintainers whose own announcement does
|
/// Pubkeys currently listed as maintainers whose own announcement does
|
||||||
/// not yet list back a confirmed maintainer. Their announcements must
|
/// not yet list back a confirmed maintainer. Their announcements must
|
||||||
@@ -337,9 +338,12 @@ pub struct RepoMembership {
|
|||||||
/// assignments in other announcements. An author who appears in no role tag
|
/// assignments in other announcements. An author who appears in no role tag
|
||||||
/// implicitly asserts maintainership for the repository's entire history.
|
/// implicitly asserts maintainership for the repository's entire history.
|
||||||
///
|
///
|
||||||
/// The owner is always a confirmed maintainer of their own repository:
|
/// An explicit active `M` path is resolved before the fixpoint is seeded. A
|
||||||
/// announcing a repository in their namespace is what creates it on this
|
/// self-`M` terminates at the lead; an announcement without an active `M`
|
||||||
/// service.
|
/// seeds its author only when resolution has not already followed an explicit
|
||||||
|
/// path. Missing, ambiguous, or cyclic explicit paths grant no authority.
|
||||||
|
/// This prevents a removed maintainer's forwarding coordinate from restoring
|
||||||
|
/// that signer to the confirmed set.
|
||||||
pub fn compute_membership(
|
pub fn compute_membership(
|
||||||
announcements: &[RepositoryAnnouncement],
|
announcements: &[RepositoryAnnouncement],
|
||||||
owner: &str,
|
owner: &str,
|
||||||
@@ -350,11 +354,35 @@ pub fn compute_membership(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier)
|
.find(|a| a.event.pubkey.to_hex() == pubkey && a.identifier == identifier)
|
||||||
};
|
};
|
||||||
if find(owner).is_none() {
|
// Resolve the selected coordinate's explicit lead path before seeding
|
||||||
|
// reciprocal membership. With no active M on the selected announcement,
|
||||||
|
// this is a legacy or deliberately leadless view rooted at the selected
|
||||||
|
// author. Once an explicit path is followed, only an active self-M may
|
||||||
|
// terminate it; incomplete, ambiguous, and cyclic paths fail closed.
|
||||||
|
let mut root = owner.to_string();
|
||||||
|
let mut followed_explicit_lead = false;
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
loop {
|
||||||
|
if !visited.insert(root.clone()) {
|
||||||
return RepoMembership::default();
|
return RepoMembership::default();
|
||||||
}
|
}
|
||||||
|
let Some(announcement) = find(&root) else {
|
||||||
|
return RepoMembership::default();
|
||||||
|
};
|
||||||
|
let active_leads = announcement.active_leads();
|
||||||
|
match active_leads.as_slice() {
|
||||||
|
[] if !followed_explicit_lead && announcement.author_role_active() => break,
|
||||||
|
[] => return RepoMembership::default(),
|
||||||
|
[lead] if lead == &root && announcement.author_role_active() => break,
|
||||||
|
[lead] if lead != &root => {
|
||||||
|
root = lead.clone();
|
||||||
|
followed_explicit_lead = true;
|
||||||
|
}
|
||||||
|
_ => return RepoMembership::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut confirmed: HashSet<String> = HashSet::from([owner.to_string()]);
|
let mut confirmed: HashSet<String> = HashSet::from([root]);
|
||||||
// Fixpoint: keep confirming listed pubkeys whose own announcement lists
|
// Fixpoint: keep confirming listed pubkeys whose own announcement lists
|
||||||
// back an already-confirmed maintainer. The confirmed set only grows, so
|
// back an already-confirmed maintainer. The confirmed set only grows, so
|
||||||
// the loop terminates.
|
// the loop terminates.
|
||||||
@@ -1456,6 +1484,131 @@ mod tests {
|
|||||||
|
|
||||||
let membership = compute_membership(&announcements, &hex(&alice), identifier);
|
let membership = compute_membership(&announcements, &hex(&alice), identifier);
|
||||||
assert!(membership.state_maintainers.contains(&hex(&bob)));
|
assert!(membership.state_maintainers.contains(&hex(&bob)));
|
||||||
|
|
||||||
|
let selected_from_bob = compute_membership(&announcements, &hex(&bob), identifier);
|
||||||
|
assert_eq!(
|
||||||
|
selected_from_bob.state_maintainers,
|
||||||
|
membership.state_maintainers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ended_selected_author_has_no_state_authority() {
|
||||||
|
let alice = create_test_keys();
|
||||||
|
let identifier = "test-repo";
|
||||||
|
|
||||||
|
let announcements = vec![parse(create_role_announcement(
|
||||||
|
&alice,
|
||||||
|
identifier,
|
||||||
|
&[("m", vec![hex(&alice), "100".to_string(), "200".to_string()])],
|
||||||
|
))];
|
||||||
|
|
||||||
|
let membership = compute_membership(&announcements, &hex(&alice), identifier);
|
||||||
|
assert!(membership.state_maintainers.is_empty());
|
||||||
|
assert!(membership.invited.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_removed_selected_maintainer_resolves_lead_without_regaining_authority() {
|
||||||
|
let alice = create_test_keys();
|
||||||
|
let bob = create_test_keys();
|
||||||
|
let identifier = "test-repo";
|
||||||
|
|
||||||
|
// Alice no longer assigns Bob. Bob's still-active acceptance and M
|
||||||
|
// redirect keep the coordinate attached to Alice, but do not seed Bob
|
||||||
|
// as a maintainer of a separate same-identifier repository.
|
||||||
|
let announcements = vec![
|
||||||
|
parse(create_role_announcement(
|
||||||
|
&alice,
|
||||||
|
identifier,
|
||||||
|
&[("M", vec![hex(&alice)])],
|
||||||
|
)),
|
||||||
|
parse(create_role_announcement(
|
||||||
|
&bob,
|
||||||
|
identifier,
|
||||||
|
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let membership = compute_membership(&announcements, &hex(&bob), identifier);
|
||||||
|
assert_eq!(membership.state_maintainers, HashSet::from([hex(&alice)]));
|
||||||
|
|
||||||
|
let repo_data = RepositoryData {
|
||||||
|
announcements,
|
||||||
|
states: Vec::new(),
|
||||||
|
};
|
||||||
|
assert!(pubkey_authorised_for_repo_owners(&bob.public_key(), &repo_data).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
pubkey_authorised_for_repo_owners(&alice.public_key(), &repo_data)
|
||||||
|
.into_iter()
|
||||||
|
.collect::<HashSet<_>>(),
|
||||||
|
HashSet::from([hex(&alice), hex(&bob)])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_incomplete_ambiguous_or_cyclic_lead_path_grants_no_state_authority() {
|
||||||
|
let alice = create_test_keys();
|
||||||
|
let bob = create_test_keys();
|
||||||
|
let identifier = "test-repo";
|
||||||
|
|
||||||
|
let bob_announcement = parse(create_role_announcement(
|
||||||
|
&bob,
|
||||||
|
identifier,
|
||||||
|
&[("M", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
|
||||||
|
));
|
||||||
|
let missing_lead = compute_membership(
|
||||||
|
std::slice::from_ref(&bob_announcement),
|
||||||
|
&hex(&bob),
|
||||||
|
identifier,
|
||||||
|
);
|
||||||
|
assert!(missing_lead.state_maintainers.is_empty());
|
||||||
|
|
||||||
|
let ambiguous = parse(create_role_announcement(
|
||||||
|
&alice,
|
||||||
|
identifier,
|
||||||
|
&[("M", vec![hex(&alice)]), ("M", vec![hex(&bob)])],
|
||||||
|
));
|
||||||
|
let ambiguous_lead = compute_membership(&[ambiguous], &hex(&alice), identifier);
|
||||||
|
assert!(ambiguous_lead.state_maintainers.is_empty());
|
||||||
|
|
||||||
|
let alice_announcement = parse(create_role_announcement(
|
||||||
|
&alice,
|
||||||
|
identifier,
|
||||||
|
&[("M", vec![hex(&bob)]), ("m", vec![hex(&alice)])],
|
||||||
|
));
|
||||||
|
let cycle = compute_membership(
|
||||||
|
&[alice_announcement, bob_announcement],
|
||||||
|
&hex(&alice),
|
||||||
|
identifier,
|
||||||
|
);
|
||||||
|
assert!(cycle.state_maintainers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_explicit_leadless_roles_still_root_selected_authority() {
|
||||||
|
let alice = create_test_keys();
|
||||||
|
let bob = create_test_keys();
|
||||||
|
let identifier = "test-repo";
|
||||||
|
|
||||||
|
let announcements = vec![
|
||||||
|
parse(create_role_announcement(
|
||||||
|
&alice,
|
||||||
|
identifier,
|
||||||
|
&[("m", vec![hex(&alice)]), ("m", vec![hex(&bob)])],
|
||||||
|
)),
|
||||||
|
parse(create_role_announcement(
|
||||||
|
&bob,
|
||||||
|
identifier,
|
||||||
|
&[("m", vec![hex(&bob)]), ("m", vec![hex(&alice)])],
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let membership = compute_membership(&announcements, &hex(&alice), identifier);
|
||||||
|
assert_eq!(
|
||||||
|
membership.state_maintainers,
|
||||||
|
HashSet::from([hex(&alice), hex(&bob)])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+102
-21
@@ -7,7 +7,32 @@
|
|||||||
/// - NIP-34: https://nips.nostr.com/34
|
/// - NIP-34: https://nips.nostr.com/34
|
||||||
/// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md
|
/// - GRASP-01: https://gitworkshop.dev/danconwaydev.com/grasp/01.md
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use nostr_sdk::prelude::{Event, Kind, ToBech32};
|
use nostr_sdk::prelude::{Event, Kind, PublicKey, ToBech32};
|
||||||
|
|
||||||
|
/// Whether a NIP-34 indexed role record is syntactically valid and active.
|
||||||
|
///
|
||||||
|
/// Values after the pubkey alternate between numeric start and end
|
||||||
|
/// boundaries. `defer` is valid only as the final end boundary and is never
|
||||||
|
/// active. Malformed history must not grant repository authority.
|
||||||
|
fn role_entry_is_active(slice: &[String]) -> bool {
|
||||||
|
if slice.len() < 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundaries = &slice[2..];
|
||||||
|
for (index, value) in boundaries.iter().enumerate() {
|
||||||
|
if value == "defer" {
|
||||||
|
let is_final_end = index % 2 == 1 && index + 1 == boundaries.len();
|
||||||
|
if !is_final_end {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if value.parse::<u64>().is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boundaries.is_empty() || boundaries.len() % 2 == 1
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: Using rust-nostr Kind variants instead of hardcoded constants:
|
// NOTE: Using rust-nostr Kind variants instead of hardcoded constants:
|
||||||
// - KIND_REPOSITORY_ANNOUNCEMENT -> Kind::GitRepoAnnouncement (30617)
|
// - KIND_REPOSITORY_ANNOUNCEMENT -> Kind::GitRepoAnnouncement (30617)
|
||||||
@@ -34,11 +59,11 @@ pub struct RepositoryAnnouncement {
|
|||||||
/// `None` when the announcement contains no role tags. Moderator (`o`)
|
/// `None` when the announcement contains no role tags. Moderator (`o`)
|
||||||
/// entries never contribute: moderators are not maintainers.
|
/// entries never contribute: moderators are not maintainers.
|
||||||
///
|
///
|
||||||
/// The lead (`M`) / co-maintainer (`m`) distinction carries no meaning
|
/// Both `M` and `m` grant maintainer authority, so they collapse into one
|
||||||
/// for this service, so both collapse into one maintainer set. Entries
|
/// maintainer set. Active `M` entries additionally drive lead resolution
|
||||||
/// whose role history shows the role has ended are excluded entirely: an
|
/// through [`Self::active_leads`]. Entries whose role history is malformed
|
||||||
/// ended role means the pubkey is no longer a maintainer, and historic
|
/// or ended are excluded entirely: historic listing never grants
|
||||||
/// listing never grants authority for a past period.
|
/// authority for a past period.
|
||||||
pub role_maintainers: Option<Vec<String>>,
|
pub role_maintainers: Option<Vec<String>>,
|
||||||
/// Whether any role tag names the author, active or ended. Per NIP-34 an
|
/// Whether any role tag names the author, active or ended. Per NIP-34 an
|
||||||
/// author without a self-entry is implicitly a maintainer for the
|
/// author without a self-entry is implicitly a maintainer for the
|
||||||
@@ -127,13 +152,14 @@ impl RepositoryAnnouncement {
|
|||||||
// Extract maintainer pubkeys from NIP-34 `M`/`m`/`o` role tags
|
// Extract maintainer pubkeys from NIP-34 `M`/`m`/`o` role tags
|
||||||
// Format: ["M"|"m"|"o", "<pubkey-hex>", "<start>", "<end>", ...]
|
// Format: ["M"|"m"|"o", "<pubkey-hex>", "<start>", "<end>", ...]
|
||||||
// A role tag lists a pubkey followed by optional alternating start/end
|
// A role tag lists a pubkey followed by optional alternating start/end
|
||||||
// history timestamps; it is currently active when it has fewer than
|
// history timestamps; it is currently active when it has no history
|
||||||
// four elements or an odd number of elements. Ended entries are
|
// or its final valid boundary is a start. `defer` is accepted only as
|
||||||
// ignored entirely. A pubkey may appear in multiple role tags: one
|
// the final end boundary. Ended and malformed entries are ignored for
|
||||||
// tag of each letter records transitions between roles, and
|
// authority. A pubkey may appear in multiple role tags: one tag of
|
||||||
// out-of-spec duplicates under the same letter are tolerated. The
|
// each letter records transitions between roles, and out-of-spec
|
||||||
// histories are consolidated - the pubkey is a maintainer while any
|
// duplicates under the same letter are tolerated. The histories are
|
||||||
// of its `M`/`m` entries is active.
|
// consolidated - the pubkey is a maintainer while any of its valid
|
||||||
|
// `M`/`m` entries is active.
|
||||||
//
|
//
|
||||||
// The moderator role (`o`) grants no maintainership: moderators are
|
// The moderator role (`o`) grants no maintainership: moderators are
|
||||||
// only empowered to have status events (kinds 1630-1633) treated as
|
// only empowered to have status events (kinds 1630-1633) treated as
|
||||||
@@ -144,7 +170,6 @@ impl RepositoryAnnouncement {
|
|||||||
// counts as a role tag: its presence suppresses the deprecated
|
// counts as a role tag: its presence suppresses the deprecated
|
||||||
// `maintainers` fallback, and a self-`o` entry is a self-role, so
|
// `maintainers` fallback, and a self-`o` entry is a self-role, so
|
||||||
// the author is not implicitly a maintainer.
|
// the author is not implicitly a maintainer.
|
||||||
let author_hex = event.pubkey.to_hex();
|
|
||||||
let mut role_tags_present = false;
|
let mut role_tags_present = false;
|
||||||
let mut author_has_role_entry = false;
|
let mut author_has_role_entry = false;
|
||||||
let mut role_active: Vec<String> = Vec::new();
|
let mut role_active: Vec<String> = Vec::new();
|
||||||
@@ -158,15 +183,21 @@ impl RepositoryAnnouncement {
|
|||||||
let Some(pubkey) = slice.get(1).filter(|value| !value.is_empty()) else {
|
let Some(pubkey) = slice.get(1).filter(|value| !value.is_empty()) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if *pubkey == author_hex {
|
let parsed_pubkey = PublicKey::from_hex(pubkey).ok();
|
||||||
|
if parsed_pubkey.as_ref() == Some(&event.pubkey) {
|
||||||
author_has_role_entry = true;
|
author_has_role_entry = true;
|
||||||
}
|
}
|
||||||
if kind == "o" {
|
if kind == "o" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let active = slice.len() < 4 || slice.len() % 2 == 1;
|
if role_entry_is_active(slice) {
|
||||||
if active && !role_active.contains(pubkey) {
|
let Some(parsed_pubkey) = parsed_pubkey else {
|
||||||
role_active.push(pubkey.clone());
|
continue;
|
||||||
|
};
|
||||||
|
let pubkey = parsed_pubkey.to_hex();
|
||||||
|
if !role_active.contains(&pubkey) {
|
||||||
|
role_active.push(pubkey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let role_maintainers = role_tags_present.then_some(role_active);
|
let role_maintainers = role_tags_present.then_some(role_active);
|
||||||
@@ -226,6 +257,32 @@ impl RepositoryAnnouncement {
|
|||||||
listed
|
listed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Distinct targets of syntactically valid, currently-active `M` records.
|
||||||
|
///
|
||||||
|
/// An active `M` is both a maintainer edge and an explicit lead pointer.
|
||||||
|
/// Duplicate records for one target are consolidated; distinct targets
|
||||||
|
/// remain visible so authority resolution can fail closed on ambiguity.
|
||||||
|
pub fn active_leads(&self) -> Vec<String> {
|
||||||
|
let mut leads = Vec::new();
|
||||||
|
for tag in self.event.tags.iter() {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
if slice.first().map(String::as_str) != Some("M") || !role_entry_is_active(slice) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(pubkey) = slice
|
||||||
|
.get(1)
|
||||||
|
.and_then(|value| PublicKey::from_hex(value).ok())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let pubkey = pubkey.to_hex();
|
||||||
|
if !leads.contains(&pubkey) {
|
||||||
|
leads.push(pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leads
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this announcement asserts an active maintainer role for its
|
/// Whether this announcement asserts an active maintainer role for its
|
||||||
/// own author.
|
/// own author.
|
||||||
///
|
///
|
||||||
@@ -928,20 +985,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_role_tag_activeness_by_element_count() {
|
fn test_role_tag_activeness_requires_valid_history() {
|
||||||
let keys = create_test_keys();
|
let keys = create_test_keys();
|
||||||
let author = keys.public_key().to_hex();
|
let author = keys.public_key().to_hex();
|
||||||
let other = create_test_keys().public_key().to_hex();
|
let other = create_test_keys().public_key().to_hex();
|
||||||
|
|
||||||
// (history values after the pubkey, expected active)
|
// (history values after the pubkey, expected active)
|
||||||
// Tag element counts: 2 -> active, 3 -> active, 4 -> ended,
|
|
||||||
// 5 -> active, 6 -> ended.
|
|
||||||
let cases: Vec<(Vec<&str>, bool)> = vec![
|
let cases: Vec<(Vec<&str>, bool)> = vec![
|
||||||
(vec![], true),
|
(vec![], true),
|
||||||
(vec!["100"], true),
|
(vec!["100"], true),
|
||||||
(vec!["100", "200"], false),
|
(vec!["100", "200"], false),
|
||||||
(vec!["100", "200", "300"], true),
|
(vec!["100", "200", "300"], true),
|
||||||
(vec!["100", "200", "300", "400"], false),
|
(vec!["100", "200", "300", "400"], false),
|
||||||
|
(vec!["100", "defer"], false),
|
||||||
|
(vec!["not-a-timestamp"], false),
|
||||||
|
(vec!["defer"], false),
|
||||||
|
(vec!["100", "defer", "300"], false),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (history, expected_active) in cases {
|
for (history, expected_active) in cases {
|
||||||
@@ -957,6 +1016,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_active_leads_include_only_valid_distinct_targets() {
|
||||||
|
let keys = create_test_keys();
|
||||||
|
let first = create_test_keys().public_key().to_hex();
|
||||||
|
let second = create_test_keys().public_key().to_hex();
|
||||||
|
let ended = create_test_keys().public_key().to_hex();
|
||||||
|
let malformed = create_test_keys().public_key().to_hex();
|
||||||
|
|
||||||
|
let announcement = role_announcement(
|
||||||
|
&keys,
|
||||||
|
vec![
|
||||||
|
("M", vec![first.clone()]),
|
||||||
|
("M", vec![first.clone(), "100".to_string()]),
|
||||||
|
("M", vec![second.clone()]),
|
||||||
|
("M", vec![ended, "100".to_string(), "200".to_string()]),
|
||||||
|
("M", vec![malformed, "not-a-timestamp".to_string()]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(announcement.active_leads(), vec![first, second]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lead_and_co_maintainer_roles_are_equivalent() {
|
fn test_lead_and_co_maintainer_roles_are_equivalent() {
|
||||||
let keys = create_test_keys();
|
let keys = create_test_keys();
|
||||||
|
|||||||
@@ -297,6 +297,81 @@ async fn state_event_rejected_as_unauthorized(client: &Client, keys: &Keys) -> b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reject_state_from_announcement_author_with_ended_self_role() {
|
||||||
|
let relay = TestRelay::start().await;
|
||||||
|
let owner_keys = Keys::generate();
|
||||||
|
|
||||||
|
let announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||||
|
.tags([
|
||||||
|
Tag::custom("d", ["test-repo"]),
|
||||||
|
Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]),
|
||||||
|
Tag::custom("relays", [relay.url()]),
|
||||||
|
Tag::custom(
|
||||||
|
"m",
|
||||||
|
[
|
||||||
|
owner_keys.public_key().to_hex(),
|
||||||
|
"0".to_string(),
|
||||||
|
"1700000000".to_string(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.finalize(&owner_keys)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let client = Client::default();
|
||||||
|
client.add_relay(relay.url()).await.unwrap();
|
||||||
|
client.connect().await;
|
||||||
|
client.send_event(&announcement).await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state_event_rejected_as_unauthorized(&client, &owner_keys).await,
|
||||||
|
"an ended selected author's state event must be rejected as unauthorized"
|
||||||
|
);
|
||||||
|
|
||||||
|
relay.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_reject_state_from_unassigned_forwarding_author() {
|
||||||
|
let relay = TestRelay::start().await;
|
||||||
|
let lead_keys = Keys::generate();
|
||||||
|
let former_keys = Keys::generate();
|
||||||
|
|
||||||
|
let lead_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||||
|
.tags([
|
||||||
|
Tag::custom("d", ["test-repo"]),
|
||||||
|
Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]),
|
||||||
|
Tag::custom("relays", [relay.url()]),
|
||||||
|
Tag::custom("M", [lead_keys.public_key().to_hex()]),
|
||||||
|
])
|
||||||
|
.finalize(&lead_keys)
|
||||||
|
.unwrap();
|
||||||
|
let former_announcement = EventBuilder::new(Kind::GitRepoAnnouncement, "")
|
||||||
|
.tags([
|
||||||
|
Tag::custom("d", ["test-repo"]),
|
||||||
|
Tag::custom("clone", [format!("https://{}/test.git", relay.domain())]),
|
||||||
|
Tag::custom("relays", [relay.url()]),
|
||||||
|
Tag::custom("M", [lead_keys.public_key().to_hex()]),
|
||||||
|
Tag::custom("m", [former_keys.public_key().to_hex()]),
|
||||||
|
])
|
||||||
|
.finalize(&former_keys)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let client = Client::default();
|
||||||
|
client.add_relay(relay.url()).await.unwrap();
|
||||||
|
client.connect().await;
|
||||||
|
client.send_event(&lead_announcement).await.unwrap();
|
||||||
|
client.send_event(&former_announcement).await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state_event_rejected_as_unauthorized(&client, &former_keys).await,
|
||||||
|
"a forwarding signer not assigned by the lead must not regain state authority"
|
||||||
|
);
|
||||||
|
|
||||||
|
relay.stop().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_reject_state_from_invited_maintainer() {
|
async fn test_reject_state_from_invited_maintainer() {
|
||||||
let relay = TestRelay::start().await;
|
let relay = TestRelay::start().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user