1use std::collections::HashMap;
22use std::fmt;
23
24use nostr_sdk::prelude::*;
25
26use crate::error::CantDoReason;
27use crate::prelude::NOSTR_REPUTATION_ATTESTATION_KIND;
28
29pub const ATTESTATION_LIFETIME_SECS: u64 = 7 * SECONDS_PER_DAY;
32pub const MAX_CLOCK_SKEW_SECS: u64 = 300;
34pub const REBIND_MAX_LIFETIME_SECS: u64 = 3_600;
36pub const MIN_ATTESTED_REVIEWS: u32 = 5;
38pub const MIN_ATTESTED_SINCE: u64 = 1_577_836_800;
41const RATING_RANGE: std::ops::RangeInclusive<u16> = 100..=500;
43const SUBJECT_MAX_LEN: usize = 64;
44const SECONDS_PER_DAY: u64 = 86_400;
45const ATTESTATION_DOCUMENT: &str = "reputation-attestation";
46const REBIND_DOCUMENT: &str = "reputation-rebind";
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum AttestationError {
51 Malformed,
53 InvalidSignature,
55 WrongKind,
57 WrongDocument,
59 MissingTag(&'static str),
61 RepeatedTag(&'static str),
63 InvalidTag(&'static str),
65 NotYetValid,
67 Expired,
69 LifetimeTooLong,
71 InvalidInput(&'static str),
74}
75
76impl fmt::Display for AttestationError {
77 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78 match self {
79 Self::Malformed => write!(f, "not a Nostr event"),
80 Self::InvalidSignature => write!(f, "invalid event id or signature"),
81 Self::WrongKind => write!(f, "not a kind {NOSTR_REPUTATION_ATTESTATION_KIND} event"),
82 Self::WrongDocument => write!(f, "missing or unexpected z tag"),
83 Self::MissingTag(t) => write!(f, "missing `{t}` tag"),
84 Self::RepeatedTag(t) => write!(f, "repeated `{t}` tag"),
85 Self::InvalidTag(t) => write!(f, "invalid `{t}` tag"),
86 Self::NotYetValid => write!(f, "created in the future"),
87 Self::Expired => write!(f, "expired"),
88 Self::LifetimeTooLong => write!(f, "lifetime exceeds the allowed maximum"),
89 Self::InvalidInput(what) => write!(f, "invalid {what}"),
90 }
91 }
92}
93
94impl std::error::Error for AttestationError {}
95
96impl AttestationError {
97 pub fn cant_do_reason(&self) -> CantDoReason {
102 match self {
103 Self::Expired => CantDoReason::ExpiredReputationAttestation,
104 _ => CantDoReason::InvalidReputationAttestation,
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ReputationAttestation {
112 pub id: EventId,
114 pub issuer: PublicKey,
116 pub destination: PublicKey,
118 pub subject: String,
120 pub reviews: u32,
122 pub rating_hundredths: u16,
124 pub since: u64,
126 pub created_at: u64,
128 pub expiration: u64,
130}
131
132impl ReputationAttestation {
133 pub fn rating(&self) -> f64 {
138 f64::from(self.rating_hundredths) / 100.0
139 }
140
141 pub fn rating_text(&self) -> String {
143 format_rating(self.rating_hundredths)
144 }
145
146 #[allow(clippy::too_many_arguments)]
153 pub fn build(
154 issuer: &Keys,
155 destination: &PublicKey,
156 subject: &str,
157 reviews: u32,
158 average: f64,
159 since: u64,
160 created_at: Timestamp,
161 lifetime: u64,
162 ) -> Result<Event, AttestationError> {
163 let created = created_at.as_secs();
164 if !valid_subject(subject) {
165 return Err(AttestationError::InvalidInput("subject"));
166 }
167 if reviews < MIN_ATTESTED_REVIEWS {
168 return Err(AttestationError::InvalidInput("reviews"));
169 }
170 let hundredths =
171 rating_hundredths(average).ok_or(AttestationError::InvalidInput("average"))?;
172 if !valid_since(since, created) {
173 return Err(AttestationError::InvalidInput("since"));
174 }
175 if lifetime == 0 || lifetime > ATTESTATION_LIFETIME_SECS {
176 return Err(AttestationError::InvalidInput("lifetime"));
177 }
178 let expiration = created
179 .checked_add(lifetime)
180 .ok_or(AttestationError::InvalidInput("lifetime"))?;
181 let tags = [
182 Tag::public_key(*destination),
183 custom_tag("subject", subject),
184 custom_tag("reviews", &reviews.to_string()),
185 custom_tag("rating", &format_rating(hundredths)),
186 custom_tag("since", &since.to_string()),
187 custom_tag("expiration", &expiration.to_string()),
188 custom_tag("z", ATTESTATION_DOCUMENT),
189 ];
190 sign(issuer, tags, created_at)
191 }
192
193 pub fn parse(
201 event: &Event,
202 now: Timestamp,
203 max_lifetime: u64,
204 ) -> Result<Self, AttestationError> {
205 verify(event, ATTESTATION_DOCUMENT)?;
206 let tags = single_tags(
207 event,
208 &["p", "subject", "reviews", "rating", "since", "expiration"],
209 )?;
210 let destination = hex_key(tags["p"], "p")?;
211 let subject = tags["subject"];
212 if !valid_subject(subject) {
213 return Err(AttestationError::InvalidTag("subject"));
214 }
215 let reviews = decimal(tags["reviews"])
216 .and_then(|r| u32::try_from(r).ok())
217 .filter(|r| *r >= MIN_ATTESTED_REVIEWS)
218 .ok_or(AttestationError::InvalidTag("reviews"))?;
219 let rating_hundredths =
220 parse_rating(tags["rating"]).ok_or(AttestationError::InvalidTag("rating"))?;
221 let created_at = event.created_at.as_secs();
222 let since = decimal(tags["since"])
223 .filter(|s| valid_since(*s, created_at))
224 .ok_or(AttestationError::InvalidTag("since"))?;
225 let expiration = decimal(tags["expiration"])
226 .filter(|e| *e > created_at)
227 .ok_or(AttestationError::InvalidTag("expiration"))?;
228 check_clock(created_at, expiration, now, max_lifetime)?;
229 Ok(Self {
230 id: event.id,
231 issuer: event.pubkey,
232 destination,
233 subject: subject.to_string(),
234 reviews,
235 rating_hundredths,
236 since,
237 created_at,
238 expiration,
239 })
240 }
241
242 pub fn parse_json(
246 json: &str,
247 now: Timestamp,
248 max_lifetime: u64,
249 ) -> Result<(Self, Event), AttestationError> {
250 let event = Event::from_json(json).map_err(|_| AttestationError::Malformed)?;
251 Self::parse(&event, now, max_lifetime).map(|a| (a, event))
252 }
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct ReputationImport {
259 pub reviews: u32,
261 pub rating_hundredths: u16,
263 pub since: u64,
265}
266
267impl ReputationImport {
268 pub fn rating(&self) -> f64 {
270 f64::from(self.rating_hundredths) / 100.0
271 }
272}
273
274impl From<&ReputationAttestation> for ReputationImport {
275 fn from(a: &ReputationAttestation) -> Self {
276 Self {
277 reviews: a.reviews,
278 rating_hundredths: a.rating_hundredths,
279 since: a.since,
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ReputationRebind {
288 pub id: EventId,
290 pub bound_identity: PublicKey,
292 pub new_identity: PublicKey,
294 pub issuer: PublicKey,
296 pub created_at: u64,
298 pub expiration: u64,
300}
301
302impl ReputationRebind {
303 pub fn build(
307 bound_identity: &Keys,
308 issuer: &PublicKey,
309 new_identity: &PublicKey,
310 created_at: Timestamp,
311 lifetime: u64,
312 ) -> Result<Event, AttestationError> {
313 if lifetime == 0 || lifetime > REBIND_MAX_LIFETIME_SECS {
314 return Err(AttestationError::InvalidInput("lifetime"));
315 }
316 let expiration = created_at.as_secs() + lifetime;
317 let tags = [
318 Tag::public_key(*new_identity),
319 custom_tag("issuer", &issuer.to_hex()),
320 custom_tag("expiration", &expiration.to_string()),
321 custom_tag("z", REBIND_DOCUMENT),
322 ];
323 sign(bound_identity, tags, created_at)
324 }
325
326 pub fn parse(event: &Event, now: Timestamp) -> Result<Self, AttestationError> {
332 verify(event, REBIND_DOCUMENT)?;
333 let tags = single_tags(event, &["p", "issuer", "expiration"])?;
334 let new_identity = hex_key(tags["p"], "p")?;
335 let issuer = hex_key(tags["issuer"], "issuer")?;
336 let created_at = event.created_at.as_secs();
337 let expiration = decimal(tags["expiration"])
338 .filter(|e| *e > created_at)
339 .ok_or(AttestationError::InvalidTag("expiration"))?;
340 check_clock(created_at, expiration, now, REBIND_MAX_LIFETIME_SECS)?;
341 Ok(Self {
342 id: event.id,
343 bound_identity: event.pubkey,
344 new_identity,
345 issuer,
346 created_at,
347 expiration,
348 })
349 }
350
351 pub fn parse_json(json: &str, now: Timestamp) -> Result<Self, AttestationError> {
353 let event = Event::from_json(json).map_err(|_| AttestationError::Malformed)?;
354 Self::parse(&event, now)
355 }
356}
357
358pub fn rating_hundredths(average: f64) -> Option<u16> {
366 if !average.is_finite() {
367 return None;
368 }
369 let (low, high) = (*RATING_RANGE.start(), *RATING_RANGE.end());
370 Some(
371 (average * 100.0)
372 .round()
373 .clamp(f64::from(low), f64::from(high)) as u16,
374 )
375}
376
377pub fn format_rating(hundredths: u16) -> String {
379 format!("{}.{:02}", hundredths / 100, hundredths % 100)
380}
381
382fn custom_tag(name: &str, value: &str) -> Tag {
383 Tag::custom(name, [value])
384}
385
386fn sign<const N: usize>(
387 keys: &Keys,
388 tags: [Tag; N],
389 created_at: Timestamp,
390) -> Result<Event, AttestationError> {
391 EventBuilder::new(Kind::Custom(NOSTR_REPUTATION_ATTESTATION_KIND), "")
392 .tags(tags)
393 .custom_created_at(created_at)
394 .finalize(keys)
395 .map_err(|_| AttestationError::InvalidInput("signing key"))
396}
397
398fn verify(event: &Event, document: &str) -> Result<(), AttestationError> {
400 event
401 .verify()
402 .map_err(|_| AttestationError::InvalidSignature)?;
403 if event.kind != Kind::Custom(NOSTR_REPUTATION_ATTESTATION_KIND) {
404 return Err(AttestationError::WrongKind);
405 }
406 let documents: Vec<&str> = values(event, "z").collect();
407 match documents.as_slice() {
408 [z] if *z == document => Ok(()),
409 _ => Err(AttestationError::WrongDocument),
410 }
411}
412
413fn values<'a>(event: &'a Event, name: &'a str) -> impl Iterator<Item = &'a str> {
415 event
416 .tags
417 .iter()
418 .filter_map(move |tag| match tag.as_slice() {
419 [kind, value, ..] if kind == name => Some(value.as_str()),
420 [kind] if kind == name => Some(""),
421 _ => None,
422 })
423}
424
425fn single_tags<'a>(
427 event: &'a Event,
428 names: &[&'static str],
429) -> Result<HashMap<&'static str, &'a str>, AttestationError> {
430 let mut found = HashMap::new();
431 for name in names {
432 let mut all = values(event, name);
433 let value = all.next().ok_or(AttestationError::MissingTag(name))?;
434 if all.next().is_some() {
435 return Err(AttestationError::RepeatedTag(name));
436 }
437 found.insert(*name, value);
438 }
439 Ok(found)
440}
441
442fn hex_key(value: &str, tag: &'static str) -> Result<PublicKey, AttestationError> {
443 let lowercase_hex = value.len() == 64
444 && value
445 .bytes()
446 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
447 if !lowercase_hex {
448 return Err(AttestationError::InvalidTag(tag));
449 }
450 PublicKey::from_hex(value).map_err(|_| AttestationError::InvalidTag(tag))
451}
452
453fn decimal(value: &str) -> Option<u64> {
455 let digits = !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit());
456 let canonical = value == "0" || !value.starts_with('0');
457 (digits && canonical).then(|| value.parse().ok()).flatten()
458}
459
460fn parse_rating(value: &str) -> Option<u16> {
462 let bytes = value.as_bytes();
463 let shape = bytes.len() == 4
464 && bytes[1] == b'.'
465 && [bytes[0], bytes[2], bytes[3]]
466 .iter()
467 .all(u8::is_ascii_digit);
468 if !shape {
469 return None;
470 }
471 let digit = |i: usize| u16::from(bytes[i] - b'0');
472 Some(digit(0) * 100 + digit(2) * 10 + digit(3)).filter(|h| RATING_RANGE.contains(h))
473}
474
475fn valid_subject(subject: &str) -> bool {
476 (1..=SUBJECT_MAX_LEN).contains(&subject.len())
477 && subject
478 .bytes()
479 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
480}
481
482fn valid_since(since: u64, created_at: u64) -> bool {
483 since.is_multiple_of(SECONDS_PER_DAY) && since >= MIN_ATTESTED_SINCE && since <= created_at
484}
485
486fn check_clock(
487 created_at: u64,
488 expiration: u64,
489 now: Timestamp,
490 max_lifetime: u64,
491) -> Result<(), AttestationError> {
492 let now = now.as_secs();
493 if created_at > now.saturating_add(MAX_CLOCK_SKEW_SECS) {
494 return Err(AttestationError::NotYetValid);
495 }
496 if now > expiration.saturating_add(MAX_CLOCK_SKEW_SECS) {
497 return Err(AttestationError::Expired);
498 }
499 if expiration - created_at > max_lifetime {
500 return Err(AttestationError::LifetimeTooLong);
501 }
502 Ok(())
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use serde_json::Value;
509
510 const VECTORS: &str = include_str!("../tests/vectors/reputation_v1.json");
511
512 const CONTEXT_REFUSALS: [&str; 3] = ["identity_mismatch", "untrusted_issuer", "own_issuer_key"];
514 const REBIND_CONTEXT_REFUSALS: [&str; 3] = [
516 "signed_by_other_identity",
517 "other_issuer",
518 "other_destination",
519 ];
520
521 fn vectors() -> Value {
522 serde_json::from_str(VECTORS).expect("vectors parse")
523 }
524
525 fn event(v: &Value) -> Event {
526 Event::from_json(v.to_string()).expect("vector event parses")
527 }
528
529 fn u64_at(v: &Value, key: &str) -> u64 {
530 v[key].as_u64().expect("number")
531 }
532
533 fn now(v: &Value) -> Timestamp {
534 Timestamp::from(u64_at(&v["context"], "now"))
535 }
536
537 fn max_lifetime(v: &Value) -> u64 {
538 u64_at(&v["context"], "max_lifetime")
539 }
540
541 fn keys(v: &Value, label: &str) -> Keys {
542 Keys::parse(v["secret_keys"][label].as_str().expect("key")).expect("valid key")
543 }
544
545 fn key(hex: &Value) -> PublicKey {
546 PublicKey::from_hex(hex.as_str().expect("hex")).expect("valid key")
547 }
548
549 #[test]
550 fn the_valid_attestation_parses_to_its_expected_fields() {
551 let v = vectors();
552 let valid = &v["attestation"]["valid"];
553 let expect = &valid["expect"];
554 let (parsed, event) = ReputationAttestation::parse_json(
555 valid["json"].as_str().expect("json"),
556 now(&v),
557 max_lifetime(&v),
558 )
559 .expect("valid attestation");
560 assert_eq!(event.id.to_hex(), valid["id"].as_str().expect("id"));
561 assert_eq!(parsed.id, event.id);
562 assert_eq!(parsed.issuer, key(&expect["issuer_key"]));
563 assert_eq!(parsed.destination, key(&expect["destination"]));
564 assert_eq!(parsed.subject, expect["subject"].as_str().expect("subject"));
565 assert_eq!(u64::from(parsed.reviews), u64_at(expect, "reviews"));
566 assert_eq!(
567 parsed.rating_text(),
568 expect["rating"].as_str().expect("rating")
569 );
570 assert_eq!(parsed.rating(), "4.87".parse::<f64>().expect("decimal"));
571 assert_eq!(parsed.since, u64_at(expect, "since"));
572 assert_eq!(parsed.created_at, u64_at(expect, "created_at"));
573 assert_eq!(parsed.expiration, u64_at(expect, "expiration"));
574 }
575
576 #[test]
577 fn every_accepted_edge_parses() {
578 let v = vectors();
579 for case in v["attestation"]["accepted"].as_array().expect("cases") {
580 let result =
581 ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v));
582 assert!(result.is_ok(), "{}: {result:?}", case["name"]);
583 }
584 }
585
586 #[test]
587 fn every_invalid_attestation_is_refused_by_the_event_or_left_to_the_caller() {
588 let v = vectors();
589 for case in v["attestation"]["invalid"].as_array().expect("cases") {
590 let name = case["name"].as_str().expect("name");
591 let result =
592 ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v));
593 if CONTEXT_REFUSALS.contains(&name) {
594 assert!(result.is_ok(), "{name} is well formed: {result:?}");
595 continue;
596 }
597 let err = result.expect_err(name);
598 let reason = serde_json::to_value(err.cant_do_reason()).expect("reason serialises");
599 assert_eq!(reason, case["reason"], "{name}: {err:?}");
600 }
601 }
602
603 #[test]
604 fn the_context_refusals_are_caught_by_the_checks_a_destination_adds() {
605 let v = vectors();
606 let ctx = &v["context"];
607 let identity = key(&ctx["proven_identity"]);
608 let own = key(&ctx["own_issuer_key"]);
609 let trusted: Vec<PublicKey> = ctx["trust_list"]
610 .as_array()
611 .expect("entries")
612 .iter()
613 .flat_map(|e| e["keys"].as_array().expect("keys").iter().map(key))
614 .collect();
615 for case in v["attestation"]["invalid"].as_array().expect("cases") {
616 let name = case["name"].as_str().expect("name");
617 if !CONTEXT_REFUSALS.contains(&name) {
618 continue;
619 }
620 let a = ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v))
621 .expect(name);
622 let refused =
623 !trusted.contains(&a.issuer) || a.issuer == own || a.destination != identity;
624 assert!(refused, "{name} must fail a context check");
625 }
626 }
627
628 #[test]
629 fn rating_rounds_half_away_from_zero_on_the_double() {
630 for case in vectors()["rating_rounding"].as_array().expect("cases") {
631 let average = case["average"].as_f64().expect("average");
632 let hundredths = rating_hundredths(average).expect("finite");
633 assert_eq!(
634 format_rating(hundredths),
635 case["rating"].as_str().expect("rating"),
636 "{average}"
637 );
638 }
639 assert_eq!(rating_hundredths(f64::NAN), None);
640 assert_eq!(rating_hundredths(f64::INFINITY), None);
641 }
642
643 #[test]
644 fn the_valid_rebind_parses_to_its_expected_fields() {
645 let v = vectors();
646 let rebind = &v["rebind"];
647 let expect = &rebind["valid"]["expect"];
648 let now = Timestamp::from(u64_at(&rebind["context"], "now"));
649 let parsed =
650 ReputationRebind::parse_json(rebind["valid"]["json"].as_str().expect("json"), now)
651 .expect("valid rebind");
652 assert_eq!(parsed.bound_identity, key(&expect["bound_identity"]));
653 assert_eq!(parsed.new_identity, key(&expect["new_identity"]));
654 assert_eq!(parsed.issuer, key(&expect["issuer"]));
655 assert_eq!(parsed.created_at, u64_at(expect, "created_at"));
656 assert_eq!(parsed.expiration, u64_at(expect, "expiration"));
657 }
658
659 #[test]
660 fn every_invalid_rebind_is_refused_by_the_event_or_by_the_issuers_checks() {
661 let v = vectors();
662 let rebind = &v["rebind"];
663 let ctx = &rebind["context"];
664 let now = Timestamp::from(u64_at(ctx, "now"));
665 let (bound, issuer) = (key(&ctx["bound_identity"]), key(&ctx["issuer_key"]));
666 let destination = key(&ctx["destination"]);
667 for case in rebind["invalid"].as_array().expect("cases") {
668 let name = case["name"].as_str().expect("name");
669 let result = ReputationRebind::parse(&event(&case["event"]), now);
670 if REBIND_CONTEXT_REFUSALS.contains(&name) {
671 let r = result.expect(name);
672 let mismatched = (
674 r.bound_identity != bound,
675 r.issuer != issuer,
676 r.new_identity != destination,
677 );
678 let expected = match name {
679 "signed_by_other_identity" => (true, false, false),
680 "other_issuer" => (false, true, false),
681 "other_destination" => (false, false, true),
682 _ => unreachable!("{name}"),
683 };
684 assert_eq!(mismatched, expected, "{name}");
685 } else {
686 assert!(result.is_err(), "{name}: {result:?}");
687 }
688 }
689 }
690
691 #[test]
692 fn an_attestation_is_never_taken_for_a_rebind_or_the_reverse() {
693 let v = vectors();
694 let attestation = event(&v["attestation"]["valid"]["event"]);
695 assert_eq!(
696 ReputationRebind::parse(&attestation, now(&v)),
697 Err(AttestationError::WrongDocument)
698 );
699 let rebind = event(&v["rebind"]["valid"]["event"]);
700 let rebind_now = Timestamp::from(u64_at(&v["rebind"]["context"], "now"));
701 assert_eq!(
702 ReputationAttestation::parse(&rebind, rebind_now, max_lifetime(&v)),
703 Err(AttestationError::WrongDocument)
704 );
705 }
706
707 #[test]
708 fn a_built_attestation_parses_back_to_what_was_built() {
709 let v = vectors();
710 let issuer = keys(&v, "issuer-a");
711 let destination = keys(&v, "identity").public_key();
712 let created = Timestamp::from(1_790_899_200);
713 let event = ReputationAttestation::build(
714 &issuer,
715 &destination,
716 "a-source-account",
717 214,
718 4.866_574_074_074_074,
719 1_696_204_800,
720 created,
721 ATTESTATION_LIFETIME_SECS,
722 )
723 .expect("builds");
724 let parsed = ReputationAttestation::parse(&event, created, ATTESTATION_LIFETIME_SECS)
725 .expect("parses");
726 assert_eq!(parsed.issuer, issuer.public_key());
727 assert_eq!(parsed.destination, destination);
728 assert_eq!(parsed.subject, "a-source-account");
729 assert_eq!(parsed.reviews, 214);
730 assert_eq!(parsed.rating_text(), "4.87");
731 assert_eq!(parsed.since, 1_696_204_800);
732 assert_eq!(
733 parsed.expiration,
734 created.as_secs() + ATTESTATION_LIFETIME_SECS
735 );
736 }
737
738 #[test]
739 fn a_built_rebind_parses_back_to_what_was_built() {
740 let v = vectors();
741 let bound = keys(&v, "identity");
742 let issuer = keys(&v, "issuer-a").public_key();
743 let new = keys(&v, "new-identity").public_key();
744 let created = Timestamp::from(1_790_899_200);
745 let event =
746 ReputationRebind::build(&bound, &issuer, &new, created, REBIND_MAX_LIFETIME_SECS)
747 .expect("builds");
748 let parsed = ReputationRebind::parse(&event, created).expect("parses");
749 assert_eq!(parsed.bound_identity, bound.public_key());
750 assert_eq!((parsed.issuer, parsed.new_identity), (issuer, new));
751 }
752
753 #[test]
754 fn the_builders_refuse_what_a_destination_would_refuse() {
755 let issuer = Keys::generate();
756 let p = Keys::generate().public_key();
757 let at = Timestamp::from(1_790_899_200);
758 let since = 1_696_204_800;
759 let life = ATTESTATION_LIFETIME_SECS;
760 let build = |subject: &str, reviews, average, since, lifetime| {
761 ReputationAttestation::build(
762 &issuer, &p, subject, reviews, average, since, at, lifetime,
763 )
764 };
765 assert_eq!(
766 build("", 5, 4.0, since, life),
767 Err(AttestationError::InvalidInput("subject"))
768 );
769 assert_eq!(
770 build("a b", 5, 4.0, since, life),
771 Err(AttestationError::InvalidInput("subject"))
772 );
773 assert_eq!(
774 build("s", 4, 4.0, since, life),
775 Err(AttestationError::InvalidInput("reviews"))
776 );
777 assert_eq!(
778 build("s", 5, f64::NAN, since, life),
779 Err(AttestationError::InvalidInput("average"))
780 );
781 assert_eq!(
782 build("s", 5, 4.0, since + 1, life),
783 Err(AttestationError::InvalidInput("since"))
784 );
785 assert_eq!(
786 build("s", 5, 4.0, at.as_secs() + 86_400, life),
787 Err(AttestationError::InvalidInput("since"))
788 );
789 assert_eq!(
790 build("s", 5, 4.0, since, 0),
791 Err(AttestationError::InvalidInput("lifetime"))
792 );
793 assert_eq!(
794 build("s", 5, 4.0, since, ATTESTATION_LIFETIME_SECS + 1),
795 Err(AttestationError::InvalidInput("lifetime"))
796 );
797 assert_eq!(
798 ReputationRebind::build(&issuer, &p, &p, at, REBIND_MAX_LIFETIME_SECS + 1),
799 Err(AttestationError::InvalidInput("lifetime"))
800 );
801 }
802
803 #[test]
804 fn a_legacy_average_below_one_is_clamped_not_refused() {
805 let issuer = Keys::generate();
806 let at = Timestamp::from(1_790_899_200);
807 let event = ReputationAttestation::build(
808 &issuer,
809 &Keys::generate().public_key(),
810 "legacy",
811 5,
812 0.5,
813 1_696_204_800,
814 at,
815 ATTESTATION_LIFETIME_SECS,
816 )
817 .expect("builds");
818 let parsed =
819 ReputationAttestation::parse(&event, at, ATTESTATION_LIFETIME_SECS).expect("parses");
820 assert_eq!(parsed.rating_text(), "1.00");
821 }
822}