lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

nip09_suppression_conformance.rs (18592B)


      1 #![cfg(feature = "json")]
      2 
      3 mod support;
      4 
      5 use std::{
      6     borrow::Cow,
      7     collections::{BTreeMap, BTreeSet},
      8     fs,
      9     path::Path,
     10 };
     11 
     12 use radroots_event::{envelope::EventEnvelope, envelope::EventEnvelopeParts};
     13 use radroots_event_codec::{
     14     admission::deletion::{
     15         RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09SuppressionDecision,
     16         admit_verified_nip09_deletion_request_event, evaluate_nip09_suppression,
     17     },
     18     verify::{RadrootsSignatureVerifiedEvent, verify_nip01_event},
     19 };
     20 use serde::{Deserialize, Serialize};
     21 use serde_json::{Value, json};
     22 
     23 const PACKAGED_VECTORS: &str = include_str!("fixtures/deletion_suppression.v1.json");
     24 const WORKSPACE_VECTOR_PATH: &str =
     25     "../../contracts/conformance/vectors/deletion/suppression.v1.json";
     26 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
     27 const OPERATION: &str = "social.deletion_request.evaluate_suppression.valid";
     28 
     29 const VECTOR_EXPECTATIONS: [(&str, &str, &str); 32] = [
     30     (
     31         "nip09_suppress_no_requests_visible",
     32         "visible",
     33         "deletion_no_authorized_reference",
     34     ),
     35     (
     36         "nip09_suppress_same_author_event_reference",
     37         "suppressed",
     38         "deletion_event_id_reference",
     39     ),
     40     (
     41         "nip09_suppress_same_author_nonmatching_reference",
     42         "visible",
     43         "deletion_no_authorized_reference",
     44     ),
     45     (
     46         "nip09_suppress_wrong_author_exact_event_reference",
     47         "visible",
     48         "deletion_request_author_mismatch",
     49     ),
     50     (
     51         "nip09_suppress_event_reference_predates_target",
     52         "suppressed",
     53         "deletion_event_id_reference",
     54     ),
     55     (
     56         "nip09_suppress_deletion_request_immune_event_reference",
     57         "visible",
     58         "deletion_request_immune",
     59     ),
     60     (
     61         "nip09_suppress_deletion_request_immune_mixed_references",
     62         "visible",
     63         "deletion_request_immune",
     64     ),
     65     (
     66         "nip09_suppress_address_cutoff_before_target",
     67         "visible",
     68         "deletion_address_cutoff_precedes_target",
     69     ),
     70     (
     71         "nip09_suppress_address_cutoff_equal_target",
     72         "suppressed",
     73         "deletion_address_reference",
     74     ),
     75     (
     76         "nip09_suppress_address_cutoff_after_target",
     77         "suppressed",
     78         "deletion_address_reference",
     79     ),
     80     (
     81         "nip09_suppress_address_wrong_kind",
     82         "visible",
     83         "deletion_no_authorized_reference",
     84     ),
     85     (
     86         "nip09_suppress_address_wrong_identifier",
     87         "visible",
     88         "deletion_no_authorized_reference",
     89     ),
     90     (
     91         "nip09_suppress_address_wrong_pubkey",
     92         "visible",
     93         "deletion_no_authorized_reference",
     94     ),
     95     (
     96         "nip09_suppress_wrong_author_address_reference",
     97         "visible",
     98         "deletion_request_author_mismatch",
     99     ),
    100     (
    101         "nip09_suppress_replaceable_kind_0",
    102         "suppressed",
    103         "deletion_address_reference",
    104     ),
    105     (
    106         "nip09_suppress_replaceable_kind_3",
    107         "suppressed",
    108         "deletion_address_reference",
    109     ),
    110     (
    111         "nip09_suppress_replaceable_kind_10000",
    112         "suppressed",
    113         "deletion_address_reference",
    114     ),
    115     (
    116         "nip09_suppress_replaceable_kind_19999",
    117         "suppressed",
    118         "deletion_address_reference",
    119     ),
    120     (
    121         "nip09_suppress_addressable_kind_30000_empty_identifier",
    122         "suppressed",
    123         "deletion_address_reference",
    124     ),
    125     (
    126         "nip09_suppress_addressable_kind_39999_opaque_identifier",
    127         "suppressed",
    128         "deletion_address_reference",
    129     ),
    130     (
    131         "nip09_suppress_combined_event_and_address_references",
    132         "suppressed",
    133         "deletion_event_id_and_address_reference",
    134     ),
    135     (
    136         "nip09_suppress_event_reference_with_stale_address",
    137         "suppressed",
    138         "deletion_event_id_reference",
    139     ),
    140     (
    141         "nip09_suppress_kind_advisory_diagnostics_ignored",
    142         "suppressed",
    143         "deletion_event_id_reference",
    144     ),
    145     (
    146         "nip09_suppress_duplicate_raw_targets_deduplicated",
    147         "suppressed",
    148         "deletion_event_id_and_address_reference",
    149     ),
    150     (
    151         "nip09_suppress_max_address_cutoff_forward_order",
    152         "suppressed",
    153         "deletion_address_reference",
    154     ),
    155     (
    156         "nip09_suppress_max_address_cutoff_reverse_order",
    157         "suppressed",
    158         "deletion_address_reference",
    159     ),
    160     (
    161         "nip09_suppress_later_revision_survives_address_cutoff",
    162         "visible",
    163         "deletion_address_cutoff_precedes_target",
    164     ),
    165     (
    166         "nip09_suppress_equal_max_cutoff_uses_lowest_request_id",
    167         "suppressed",
    168         "deletion_address_reference",
    169     ),
    170     (
    171         "nip09_suppress_multiple_event_references_use_lowest_request_id",
    172         "suppressed",
    173         "deletion_event_id_reference",
    174     ),
    175     (
    176         "nip09_suppress_repeated_request_is_idempotent",
    177         "suppressed",
    178         "deletion_event_id_reference",
    179     ),
    180     (
    181         "nip09_suppress_unauthorized_event_plus_stale_authorized_address",
    182         "visible",
    183         "deletion_address_cutoff_precedes_target",
    184     ),
    185     (
    186         "nip09_suppress_exact_event_deletes_later_replacement",
    187         "suppressed",
    188         "deletion_event_id_reference",
    189     ),
    190 ];
    191 
    192 #[derive(Debug, Deserialize)]
    193 #[serde(deny_unknown_fields)]
    194 struct Suite {
    195     suite: String,
    196     contract_version: String,
    197     vectors: Vec<Vector>,
    198 }
    199 
    200 #[derive(Debug, Deserialize)]
    201 #[serde(deny_unknown_fields)]
    202 struct Vector {
    203     id: String,
    204     kind: String,
    205     input: Input,
    206     expected: ExpectedDecision,
    207 }
    208 
    209 #[derive(Debug, Deserialize)]
    210 #[serde(deny_unknown_fields)]
    211 struct Input {
    212     request_event_jsons: Vec<String>,
    213     target_event_json: String,
    214 }
    215 
    216 #[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
    217 #[serde(deny_unknown_fields)]
    218 struct ExpectedDecision {
    219     outcome: String,
    220     reason: String,
    221     event_reference: Option<ExpectedEventReference>,
    222     address_reference: Option<ExpectedAddressReference>,
    223 }
    224 
    225 #[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
    226 #[serde(deny_unknown_fields)]
    227 struct ExpectedEventReference {
    228     request_id: String,
    229 }
    230 
    231 #[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
    232 #[serde(deny_unknown_fields)]
    233 struct ExpectedAddressReference {
    234     coordinate: String,
    235     inclusive_cutoff: u64,
    236     request_id: String,
    237 }
    238 
    239 #[derive(Clone, Debug, Deserialize, Serialize)]
    240 #[serde(deny_unknown_fields)]
    241 struct RawEvent {
    242     id: String,
    243     pubkey: String,
    244     created_at: u64,
    245     kind: u32,
    246     tags: Vec<Vec<String>>,
    247     content: String,
    248     sig: String,
    249 }
    250 
    251 #[test]
    252 fn suppression_vectors_execute_the_public_evaluator() {
    253     let vectors = conformance_vectors();
    254     let raw_suite: Value =
    255         serde_json::from_str(&vectors).expect("NIP-09 suppression JSON must parse");
    256     validate_no_forbidden_corpus_metadata(&raw_suite, "$")
    257         .unwrap_or_else(|error| panic!("{error}"));
    258     assert_exact_nullable_evidence_shape(&raw_suite);
    259 
    260     let suite: Suite =
    261         serde_json::from_str(&vectors).expect("NIP-09 suppression vectors must parse");
    262     assert_eq!(suite.suite, "nip09_suppression_evaluator");
    263     assert_eq!(suite.contract_version, "1.0.0");
    264     assert_vector_inventory(&suite.vectors);
    265     assert_signed_event_inventory(&suite.vectors);
    266 
    267     for vector in &suite.vectors {
    268         execute(vector);
    269     }
    270 }
    271 
    272 fn assert_vector_inventory(vectors: &[Vector]) {
    273     let expected = VECTOR_EXPECTATIONS
    274         .into_iter()
    275         .map(|(id, outcome, reason)| (id, (OPERATION, outcome, reason)))
    276         .collect::<BTreeMap<_, _>>();
    277     let actual = vectors
    278         .iter()
    279         .map(|vector| {
    280             (
    281                 vector.id.as_str(),
    282                 (
    283                     vector.kind.as_str(),
    284                     vector.expected.outcome.as_str(),
    285                     vector.expected.reason.as_str(),
    286                 ),
    287             )
    288         })
    289         .collect::<BTreeMap<_, _>>();
    290     assert_eq!(vectors.len(), VECTOR_EXPECTATIONS.len());
    291     assert_eq!(actual, expected);
    292     assert!(vectors.iter().all(|vector| vector.id.starts_with("nip09_")));
    293 
    294     let reasons = vectors
    295         .iter()
    296         .map(|vector| vector.expected.reason.as_str())
    297         .collect::<BTreeSet<_>>();
    298     assert_eq!(
    299         reasons,
    300         BTreeSet::from([
    301             "deletion_address_cutoff_precedes_target",
    302             "deletion_address_reference",
    303             "deletion_event_id_and_address_reference",
    304             "deletion_event_id_reference",
    305             "deletion_no_authorized_reference",
    306             "deletion_request_author_mismatch",
    307             "deletion_request_immune",
    308         ])
    309     );
    310 }
    311 
    312 fn assert_signed_event_inventory(vectors: &[Vector]) {
    313     let event_jsons = vectors
    314         .iter()
    315         .flat_map(|vector| {
    316             std::iter::once(vector.input.target_event_json.as_str())
    317                 .chain(vector.input.request_event_jsons.iter().map(String::as_str))
    318         })
    319         .collect::<BTreeSet<_>>();
    320     assert_eq!(event_jsons.len(), 38, "signed event inventory drifted");
    321 
    322     let mut deletion_request_count = 0usize;
    323     for event_json in event_jsons {
    324         let verified = verified_event(event_json, "signed event inventory");
    325         if verified.event().kind_u32() == 5 {
    326             admit_verified_nip09_deletion_request_event(verified)
    327                 .expect("every fixed kind-5 corpus event must admit");
    328             deletion_request_count += 1;
    329         }
    330     }
    331     assert_eq!(deletion_request_count, 30);
    332 }
    333 
    334 fn execute(vector: &Vector) {
    335     let target = verified_event(&vector.input.target_event_json, &vector.id);
    336     let requests = vector
    337         .input
    338         .request_event_jsons
    339         .iter()
    340         .map(|event_json| admitted_request(event_json, &vector.id))
    341         .collect::<Vec<_>>();
    342     let target_before = target.clone();
    343     let requests_before = requests.clone();
    344 
    345     let decision = evaluate_nip09_suppression(&target, &requests);
    346     assert_eq!(actual_decision(&decision), vector.expected, "{}", vector.id);
    347     assert_eq!(target, target_before, "{} target changed", vector.id);
    348     assert_eq!(requests, requests_before, "{} requests changed", vector.id);
    349 
    350     let repeated = evaluate_nip09_suppression(&target, &requests);
    351     assert_eq!(
    352         repeated, decision,
    353         "{} repeat evaluation drifted",
    354         vector.id
    355     );
    356     let mut reversed = requests.clone();
    357     reversed.reverse();
    358     assert_eq!(
    359         evaluate_nip09_suppression(&target, &reversed),
    360         decision,
    361         "{} request order changed the decision",
    362         vector.id
    363     );
    364 }
    365 
    366 fn actual_decision(decision: &RadrootsNip09SuppressionDecision) -> ExpectedDecision {
    367     ExpectedDecision {
    368         outcome: decision.outcome().code().to_owned(),
    369         reason: decision.reason().code().to_owned(),
    370         event_reference: decision
    371             .event_reference()
    372             .map(|evidence| ExpectedEventReference {
    373                 request_id: evidence.request_id().to_hex(),
    374             }),
    375         address_reference: decision
    376             .address_reference()
    377             .map(|evidence| ExpectedAddressReference {
    378                 coordinate: evidence.coordinate().as_str().to_owned(),
    379                 inclusive_cutoff: evidence.inclusive_cutoff(),
    380                 request_id: evidence.request_id().to_hex(),
    381             }),
    382     }
    383 }
    384 
    385 fn admitted_request(
    386     event_json: &str,
    387     vector_id: &str,
    388 ) -> RadrootsAdmittedNip09DeletionRequestEvent {
    389     let verified = verified_event(event_json, vector_id);
    390     admit_verified_nip09_deletion_request_event(verified)
    391         .unwrap_or_else(|error| panic!("{vector_id} request admission failed: {error}"))
    392 }
    393 
    394 fn verified_event(event_json: &str, vector_id: &str) -> RadrootsSignatureVerifiedEvent {
    395     verify_nip01_event(event_envelope(event_json, vector_id))
    396         .unwrap_or_else(|error| panic!("{vector_id} signature verification failed: {error}"))
    397 }
    398 
    399 fn event_envelope(event_json: &str, vector_id: &str) -> EventEnvelope {
    400     let raw: RawEvent = serde_json::from_str(event_json)
    401         .unwrap_or_else(|error| panic!("{vector_id} signed event JSON is invalid: {error}"));
    402     assert_eq!(
    403         serde_json::to_string(&raw).expect("raw event serialization"),
    404         event_json,
    405         "{vector_id} signed event JSON must be canonical and compact"
    406     );
    407     EventEnvelope::new(EventEnvelopeParts {
    408         id: raw.id,
    409         author: raw.pubkey,
    410         created_at: raw.created_at,
    411         kind: raw.kind,
    412         tags: raw.tags,
    413         content: raw.content,
    414         sig: raw.sig,
    415     })
    416     .unwrap_or_else(|error| panic!("{vector_id} event envelope failed: {error}"))
    417 }
    418 
    419 fn assert_exact_nullable_evidence_shape(suite: &Value) {
    420     let suite_object = suite.as_object().expect("suite object");
    421     assert_eq!(
    422         suite_object
    423             .keys()
    424             .map(String::as_str)
    425             .collect::<BTreeSet<_>>(),
    426         BTreeSet::from(["contract_version", "suite", "vectors"])
    427     );
    428     for vector in suite["vectors"].as_array().expect("vectors array") {
    429         assert_eq!(
    430             vector
    431                 .as_object()
    432                 .expect("vector object")
    433                 .keys()
    434                 .map(String::as_str)
    435                 .collect::<BTreeSet<_>>(),
    436             BTreeSet::from(["expected", "id", "input", "kind"])
    437         );
    438         assert_eq!(
    439             vector["input"]
    440                 .as_object()
    441                 .expect("input object")
    442                 .keys()
    443                 .map(String::as_str)
    444                 .collect::<BTreeSet<_>>(),
    445             BTreeSet::from(["request_event_jsons", "target_event_json"])
    446         );
    447         assert_eq!(
    448             vector["expected"]
    449                 .as_object()
    450                 .expect("expected object")
    451                 .keys()
    452                 .map(String::as_str)
    453                 .collect::<BTreeSet<_>>(),
    454             BTreeSet::from(["address_reference", "event_reference", "outcome", "reason",])
    455         );
    456     }
    457 }
    458 
    459 fn conformance_vectors() -> Cow<'static, str> {
    460     let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH);
    461     match fs::read_to_string(&workspace_path) {
    462         Ok(canonical) => {
    463             assert_eq!(
    464                 canonical,
    465                 PACKAGED_VECTORS,
    466                 "packaged suppression vectors must match {}",
    467                 workspace_path.display()
    468             );
    469             Cow::Owned(canonical)
    470         }
    471         Err(error)
    472             if error.kind() == std::io::ErrorKind::NotFound
    473                 && !Path::new(env!("CARGO_MANIFEST_DIR"))
    474                     .join(WORKSPACE_CONTRACT_MARKER_PATH)
    475                     .is_file() =>
    476         {
    477             Cow::Borrowed(PACKAGED_VECTORS)
    478         }
    479         Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
    480     }
    481 }
    482 
    483 fn validate_no_forbidden_corpus_metadata(value: &Value, path: &str) -> Result<(), String> {
    484     match value {
    485         Value::Object(object) => {
    486             for (key, child) in object {
    487                 if is_forbidden_metadata_key(key) {
    488                     return Err(format!(
    489                         "frozen NIP-09 suppression corpus contains forbidden key {path}.{key}"
    490                     ));
    491                 }
    492                 validate_no_forbidden_corpus_metadata(child, &format!("{path}.{key}"))?;
    493             }
    494         }
    495         Value::Array(values) => {
    496             for (index, child) in values.iter().enumerate() {
    497                 validate_no_forbidden_corpus_metadata(child, &format!("{path}[{index}]"))?;
    498             }
    499         }
    500         Value::String(string) => {
    501             if string.to_ascii_lowercase().contains("nsec1") {
    502                 return Err(format!(
    503                     "frozen NIP-09 suppression corpus contains nsec material at {path}"
    504                 ));
    505             }
    506             if contains_approved_fixture_secret(string) {
    507                 return Err(format!(
    508                     "frozen NIP-09 suppression corpus contains a fixture secret at {path}"
    509                 ));
    510             }
    511         }
    512         _ => {}
    513     }
    514     Ok(())
    515 }
    516 
    517 fn is_forbidden_metadata_key(key: &str) -> bool {
    518     let normalized = key.to_ascii_lowercase();
    519     matches!(normalized.as_str(), "base" | "mutation")
    520         || normalized.contains("source")
    521         || normalized.contains("seed")
    522         || normalized.contains("generator")
    523         || normalized.contains("recipe")
    524         || normalized.contains("secret_key")
    525         || normalized.contains("private_key")
    526         || normalized.contains("signing_key")
    527         || normalized.contains("authorization")
    528         || normalized.contains("authorized")
    529         || normalized.contains("store_mutation")
    530         || normalized == "effect"
    531         || normalized.ends_with("_effect")
    532         || normalized == "effects"
    533 }
    534 
    535 fn contains_approved_fixture_secret(value: &str) -> bool {
    536     let normalized = value.to_ascii_lowercase();
    537     support::approved_fixture_identities()
    538         .iter()
    539         .any(|identity| normalized.contains(identity.secret_key_hex))
    540 }
    541 
    542 #[test]
    543 fn suppression_corpus_hygiene_rejects_generation_and_effect_authority_metadata() {
    544     for key in [
    545         "SOURCE",
    546         "SeEd",
    547         "GENERATOR",
    548         "MuTaTiOn",
    549         "AuThOrIzAtIoN",
    550         "store_effect",
    551     ] {
    552         let value = Value::Object([(key.to_owned(), Value::Null)].into_iter().collect());
    553         let error = validate_no_forbidden_corpus_metadata(&value, "$")
    554             .expect_err("forbidden corpus metadata must fail");
    555         assert!(error.contains(key), "{error}");
    556     }
    557     assert!(validate_no_forbidden_corpus_metadata(&json!("NSEC1FORBIDDEN"), "$").is_err());
    558     for identity in support::approved_fixture_identities() {
    559         assert!(
    560             validate_no_forbidden_corpus_metadata(&json!(identity.secret_key_hex), "$").is_err()
    561         );
    562     }
    563 }
    564 
    565 #[test]
    566 fn suppression_corpus_shapes_reject_unknown_fields() {
    567     let input = json!({
    568         "request_event_jsons": [],
    569         "target_event_json": "{}",
    570         "unexpected": null
    571     });
    572     serde_json::from_value::<Input>(input).expect_err("unknown input fields must fail");
    573 
    574     let expected = json!({
    575         "outcome": "visible",
    576         "reason": "deletion_no_authorized_reference",
    577         "event_reference": null,
    578         "address_reference": null,
    579         "unexpected": null
    580     });
    581     serde_json::from_value::<ExpectedDecision>(expected)
    582         .expect_err("unknown expected fields must fail");
    583 
    584     let evidence = json!({"request_id": "a", "unexpected": null});
    585     serde_json::from_value::<ExpectedEventReference>(evidence)
    586         .expect_err("unknown evidence fields must fail");
    587 }