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> {
331 verify(event, REBIND_DOCUMENT)?;
332 let tags = single_tags(event, &["p", "issuer", "expiration"])?;
333 let new_identity = hex_key(tags["p"], "p")?;
334 let issuer = hex_key(tags["issuer"], "issuer")?;
335 let created_at = event.created_at.as_secs();
336 let expiration = decimal(tags["expiration"])
337 .filter(|e| *e > created_at)
338 .ok_or(AttestationError::InvalidTag("expiration"))?;
339 check_clock(created_at, expiration, now, REBIND_MAX_LIFETIME_SECS)?;
340 Ok(Self {
341 id: event.id,
342 bound_identity: event.pubkey,
343 new_identity,
344 issuer,
345 created_at,
346 expiration,
347 })
348 }
349
350 pub fn parse_json(json: &str, now: Timestamp) -> Result<Self, AttestationError> {
352 let event = Event::from_json(json).map_err(|_| AttestationError::Malformed)?;
353 Self::parse(&event, now)
354 }
355}
356
357pub fn rating_hundredths(average: f64) -> Option<u16> {
365 if !average.is_finite() {
366 return None;
367 }
368 let (low, high) = (*RATING_RANGE.start(), *RATING_RANGE.end());
369 Some(
370 (average * 100.0)
371 .round()
372 .clamp(f64::from(low), f64::from(high)) as u16,
373 )
374}
375
376pub fn format_rating(hundredths: u16) -> String {
378 format!("{}.{:02}", hundredths / 100, hundredths % 100)
379}
380
381fn custom_tag(name: &str, value: &str) -> Tag {
382 Tag::custom(name, [value])
383}
384
385fn sign<const N: usize>(
386 keys: &Keys,
387 tags: [Tag; N],
388 created_at: Timestamp,
389) -> Result<Event, AttestationError> {
390 EventBuilder::new(Kind::Custom(NOSTR_REPUTATION_ATTESTATION_KIND), "")
391 .tags(tags)
392 .custom_created_at(created_at)
393 .finalize(keys)
394 .map_err(|_| AttestationError::InvalidInput("signing key"))
395}
396
397fn verify(event: &Event, document: &str) -> Result<(), AttestationError> {
399 event
400 .verify()
401 .map_err(|_| AttestationError::InvalidSignature)?;
402 if event.kind != Kind::Custom(NOSTR_REPUTATION_ATTESTATION_KIND) {
403 return Err(AttestationError::WrongKind);
404 }
405 let documents: Vec<&str> = values(event, "z").collect();
406 match documents.as_slice() {
407 [z] if *z == document => Ok(()),
408 _ => Err(AttestationError::WrongDocument),
409 }
410}
411
412fn values<'a>(event: &'a Event, name: &'a str) -> impl Iterator<Item = &'a str> {
414 event
415 .tags
416 .iter()
417 .filter_map(move |tag| match tag.as_slice() {
418 [kind, value, ..] if kind == name => Some(value.as_str()),
419 [kind] if kind == name => Some(""),
420 _ => None,
421 })
422}
423
424fn single_tags<'a>(
426 event: &'a Event,
427 names: &[&'static str],
428) -> Result<HashMap<&'static str, &'a str>, AttestationError> {
429 let mut found = HashMap::new();
430 for name in names {
431 let mut all = values(event, name);
432 let value = all.next().ok_or(AttestationError::MissingTag(name))?;
433 if all.next().is_some() {
434 return Err(AttestationError::RepeatedTag(name));
435 }
436 found.insert(*name, value);
437 }
438 Ok(found)
439}
440
441fn hex_key(value: &str, tag: &'static str) -> Result<PublicKey, AttestationError> {
442 let lowercase_hex = value.len() == 64
443 && value
444 .bytes()
445 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
446 if !lowercase_hex {
447 return Err(AttestationError::InvalidTag(tag));
448 }
449 PublicKey::from_hex(value).map_err(|_| AttestationError::InvalidTag(tag))
450}
451
452fn decimal(value: &str) -> Option<u64> {
454 let digits = !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit());
455 let canonical = value == "0" || !value.starts_with('0');
456 (digits && canonical).then(|| value.parse().ok()).flatten()
457}
458
459fn parse_rating(value: &str) -> Option<u16> {
461 let bytes = value.as_bytes();
462 let shape = bytes.len() == 4
463 && bytes[1] == b'.'
464 && [bytes[0], bytes[2], bytes[3]]
465 .iter()
466 .all(u8::is_ascii_digit);
467 if !shape {
468 return None;
469 }
470 let digit = |i: usize| u16::from(bytes[i] - b'0');
471 Some(digit(0) * 100 + digit(2) * 10 + digit(3)).filter(|h| RATING_RANGE.contains(h))
472}
473
474fn valid_subject(subject: &str) -> bool {
475 (1..=SUBJECT_MAX_LEN).contains(&subject.len())
476 && subject
477 .bytes()
478 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
479}
480
481fn valid_since(since: u64, created_at: u64) -> bool {
482 since.is_multiple_of(SECONDS_PER_DAY) && since >= MIN_ATTESTED_SINCE && since <= created_at
483}
484
485fn check_clock(
486 created_at: u64,
487 expiration: u64,
488 now: Timestamp,
489 max_lifetime: u64,
490) -> Result<(), AttestationError> {
491 let now = now.as_secs();
492 if created_at > now.saturating_add(MAX_CLOCK_SKEW_SECS) {
493 return Err(AttestationError::NotYetValid);
494 }
495 if now > expiration.saturating_add(MAX_CLOCK_SKEW_SECS) {
496 return Err(AttestationError::Expired);
497 }
498 if expiration - created_at > max_lifetime {
499 return Err(AttestationError::LifetimeTooLong);
500 }
501 Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use serde_json::Value;
508
509 const VECTORS: &str = include_str!("../tests/vectors/reputation_v1.json");
510
511 const CONTEXT_REFUSALS: [&str; 3] = ["identity_mismatch", "untrusted_issuer", "own_issuer_key"];
513 const REBIND_CONTEXT_REFUSALS: [&str; 2] = ["signed_by_other_identity", "other_issuer"];
515
516 fn vectors() -> Value {
517 serde_json::from_str(VECTORS).expect("vectors parse")
518 }
519
520 fn event(v: &Value) -> Event {
521 Event::from_json(v.to_string()).expect("vector event parses")
522 }
523
524 fn u64_at(v: &Value, key: &str) -> u64 {
525 v[key].as_u64().expect("number")
526 }
527
528 fn now(v: &Value) -> Timestamp {
529 Timestamp::from(u64_at(&v["context"], "now"))
530 }
531
532 fn max_lifetime(v: &Value) -> u64 {
533 u64_at(&v["context"], "max_lifetime")
534 }
535
536 fn keys(v: &Value, label: &str) -> Keys {
537 Keys::parse(v["secret_keys"][label].as_str().expect("key")).expect("valid key")
538 }
539
540 fn key(hex: &Value) -> PublicKey {
541 PublicKey::from_hex(hex.as_str().expect("hex")).expect("valid key")
542 }
543
544 #[test]
545 fn the_valid_attestation_parses_to_its_expected_fields() {
546 let v = vectors();
547 let valid = &v["attestation"]["valid"];
548 let expect = &valid["expect"];
549 let (parsed, event) = ReputationAttestation::parse_json(
550 valid["json"].as_str().expect("json"),
551 now(&v),
552 max_lifetime(&v),
553 )
554 .expect("valid attestation");
555 assert_eq!(event.id.to_hex(), valid["id"].as_str().expect("id"));
556 assert_eq!(parsed.id, event.id);
557 assert_eq!(parsed.issuer, key(&expect["issuer_key"]));
558 assert_eq!(parsed.destination, key(&expect["destination"]));
559 assert_eq!(parsed.subject, expect["subject"].as_str().expect("subject"));
560 assert_eq!(u64::from(parsed.reviews), u64_at(expect, "reviews"));
561 assert_eq!(
562 parsed.rating_text(),
563 expect["rating"].as_str().expect("rating")
564 );
565 assert_eq!(parsed.rating(), "4.87".parse::<f64>().expect("decimal"));
566 assert_eq!(parsed.since, u64_at(expect, "since"));
567 assert_eq!(parsed.created_at, u64_at(expect, "created_at"));
568 assert_eq!(parsed.expiration, u64_at(expect, "expiration"));
569 }
570
571 #[test]
572 fn every_accepted_edge_parses() {
573 let v = vectors();
574 for case in v["attestation"]["accepted"].as_array().expect("cases") {
575 let result =
576 ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v));
577 assert!(result.is_ok(), "{}: {result:?}", case["name"]);
578 }
579 }
580
581 #[test]
582 fn every_invalid_attestation_is_refused_by_the_event_or_left_to_the_caller() {
583 let v = vectors();
584 for case in v["attestation"]["invalid"].as_array().expect("cases") {
585 let name = case["name"].as_str().expect("name");
586 let result =
587 ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v));
588 if CONTEXT_REFUSALS.contains(&name) {
589 assert!(result.is_ok(), "{name} is well formed: {result:?}");
590 continue;
591 }
592 let err = result.expect_err(name);
593 let reason = serde_json::to_value(err.cant_do_reason()).expect("reason serialises");
594 assert_eq!(reason, case["reason"], "{name}: {err:?}");
595 }
596 }
597
598 #[test]
599 fn the_context_refusals_are_caught_by_the_checks_a_destination_adds() {
600 let v = vectors();
601 let ctx = &v["context"];
602 let identity = key(&ctx["proven_identity"]);
603 let own = key(&ctx["own_issuer_key"]);
604 let trusted: Vec<PublicKey> = ctx["trust_list"]
605 .as_array()
606 .expect("entries")
607 .iter()
608 .flat_map(|e| e["keys"].as_array().expect("keys").iter().map(key))
609 .collect();
610 for case in v["attestation"]["invalid"].as_array().expect("cases") {
611 let name = case["name"].as_str().expect("name");
612 if !CONTEXT_REFUSALS.contains(&name) {
613 continue;
614 }
615 let a = ReputationAttestation::parse(&event(&case["event"]), now(&v), max_lifetime(&v))
616 .expect(name);
617 let refused =
618 !trusted.contains(&a.issuer) || a.issuer == own || a.destination != identity;
619 assert!(refused, "{name} must fail a context check");
620 }
621 }
622
623 #[test]
624 fn rating_rounds_half_away_from_zero_on_the_double() {
625 for case in vectors()["rating_rounding"].as_array().expect("cases") {
626 let average = case["average"].as_f64().expect("average");
627 let hundredths = rating_hundredths(average).expect("finite");
628 assert_eq!(
629 format_rating(hundredths),
630 case["rating"].as_str().expect("rating"),
631 "{average}"
632 );
633 }
634 assert_eq!(rating_hundredths(f64::NAN), None);
635 assert_eq!(rating_hundredths(f64::INFINITY), None);
636 }
637
638 #[test]
639 fn the_valid_rebind_parses_to_its_expected_fields() {
640 let v = vectors();
641 let rebind = &v["rebind"];
642 let expect = &rebind["valid"]["expect"];
643 let now = Timestamp::from(u64_at(&rebind["context"], "now"));
644 let parsed =
645 ReputationRebind::parse_json(rebind["valid"]["json"].as_str().expect("json"), now)
646 .expect("valid rebind");
647 assert_eq!(parsed.bound_identity, key(&expect["bound_identity"]));
648 assert_eq!(parsed.new_identity, key(&expect["new_identity"]));
649 assert_eq!(parsed.issuer, key(&expect["issuer"]));
650 assert_eq!(parsed.created_at, u64_at(expect, "created_at"));
651 assert_eq!(parsed.expiration, u64_at(expect, "expiration"));
652 }
653
654 #[test]
655 fn every_invalid_rebind_is_refused_by_the_event_or_by_the_issuers_checks() {
656 let v = vectors();
657 let rebind = &v["rebind"];
658 let ctx = &rebind["context"];
659 let now = Timestamp::from(u64_at(ctx, "now"));
660 let (bound, issuer) = (key(&ctx["bound_identity"]), key(&ctx["issuer_key"]));
661 for case in rebind["invalid"].as_array().expect("cases") {
662 let name = case["name"].as_str().expect("name");
663 let result = ReputationRebind::parse(&event(&case["event"]), now);
664 if REBIND_CONTEXT_REFUSALS.contains(&name) {
665 let r = result.expect(name);
666 assert!(r.bound_identity != bound || r.issuer != issuer, "{name}");
667 } else {
668 assert!(result.is_err(), "{name}: {result:?}");
669 }
670 }
671 }
672
673 #[test]
674 fn an_attestation_is_never_taken_for_a_rebind_or_the_reverse() {
675 let v = vectors();
676 let attestation = event(&v["attestation"]["valid"]["event"]);
677 assert_eq!(
678 ReputationRebind::parse(&attestation, now(&v)),
679 Err(AttestationError::WrongDocument)
680 );
681 let rebind = event(&v["rebind"]["valid"]["event"]);
682 let rebind_now = Timestamp::from(u64_at(&v["rebind"]["context"], "now"));
683 assert_eq!(
684 ReputationAttestation::parse(&rebind, rebind_now, max_lifetime(&v)),
685 Err(AttestationError::WrongDocument)
686 );
687 }
688
689 #[test]
690 fn a_built_attestation_parses_back_to_what_was_built() {
691 let v = vectors();
692 let issuer = keys(&v, "issuer-a");
693 let destination = keys(&v, "identity").public_key();
694 let created = Timestamp::from(1_790_899_200);
695 let event = ReputationAttestation::build(
696 &issuer,
697 &destination,
698 "a-source-account",
699 214,
700 4.866_574_074_074_074,
701 1_696_204_800,
702 created,
703 ATTESTATION_LIFETIME_SECS,
704 )
705 .expect("builds");
706 let parsed = ReputationAttestation::parse(&event, created, ATTESTATION_LIFETIME_SECS)
707 .expect("parses");
708 assert_eq!(parsed.issuer, issuer.public_key());
709 assert_eq!(parsed.destination, destination);
710 assert_eq!(parsed.subject, "a-source-account");
711 assert_eq!(parsed.reviews, 214);
712 assert_eq!(parsed.rating_text(), "4.87");
713 assert_eq!(parsed.since, 1_696_204_800);
714 assert_eq!(
715 parsed.expiration,
716 created.as_secs() + ATTESTATION_LIFETIME_SECS
717 );
718 }
719
720 #[test]
721 fn a_built_rebind_parses_back_to_what_was_built() {
722 let v = vectors();
723 let bound = keys(&v, "identity");
724 let issuer = keys(&v, "issuer-a").public_key();
725 let new = keys(&v, "new-identity").public_key();
726 let created = Timestamp::from(1_790_899_200);
727 let event =
728 ReputationRebind::build(&bound, &issuer, &new, created, REBIND_MAX_LIFETIME_SECS)
729 .expect("builds");
730 let parsed = ReputationRebind::parse(&event, created).expect("parses");
731 assert_eq!(parsed.bound_identity, bound.public_key());
732 assert_eq!((parsed.issuer, parsed.new_identity), (issuer, new));
733 }
734
735 #[test]
736 fn the_builders_refuse_what_a_destination_would_refuse() {
737 let issuer = Keys::generate();
738 let p = Keys::generate().public_key();
739 let at = Timestamp::from(1_790_899_200);
740 let since = 1_696_204_800;
741 let life = ATTESTATION_LIFETIME_SECS;
742 let build = |subject: &str, reviews, average, since, lifetime| {
743 ReputationAttestation::build(
744 &issuer, &p, subject, reviews, average, since, at, lifetime,
745 )
746 };
747 assert_eq!(
748 build("", 5, 4.0, since, life),
749 Err(AttestationError::InvalidInput("subject"))
750 );
751 assert_eq!(
752 build("a b", 5, 4.0, since, life),
753 Err(AttestationError::InvalidInput("subject"))
754 );
755 assert_eq!(
756 build("s", 4, 4.0, since, life),
757 Err(AttestationError::InvalidInput("reviews"))
758 );
759 assert_eq!(
760 build("s", 5, f64::NAN, since, life),
761 Err(AttestationError::InvalidInput("average"))
762 );
763 assert_eq!(
764 build("s", 5, 4.0, since + 1, life),
765 Err(AttestationError::InvalidInput("since"))
766 );
767 assert_eq!(
768 build("s", 5, 4.0, at.as_secs() + 86_400, life),
769 Err(AttestationError::InvalidInput("since"))
770 );
771 assert_eq!(
772 build("s", 5, 4.0, since, 0),
773 Err(AttestationError::InvalidInput("lifetime"))
774 );
775 assert_eq!(
776 build("s", 5, 4.0, since, ATTESTATION_LIFETIME_SECS + 1),
777 Err(AttestationError::InvalidInput("lifetime"))
778 );
779 assert_eq!(
780 ReputationRebind::build(&issuer, &p, &p, at, REBIND_MAX_LIFETIME_SECS + 1),
781 Err(AttestationError::InvalidInput("lifetime"))
782 );
783 }
784
785 #[test]
786 fn a_legacy_average_below_one_is_clamped_not_refused() {
787 let issuer = Keys::generate();
788 let at = Timestamp::from(1_790_899_200);
789 let event = ReputationAttestation::build(
790 &issuer,
791 &Keys::generate().public_key(),
792 "legacy",
793 5,
794 0.5,
795 1_696_204_800,
796 at,
797 ATTESTATION_LIFETIME_SECS,
798 )
799 .expect("builds");
800 let parsed =
801 ReputationAttestation::parse(&event, at, ATTESTATION_LIFETIME_SECS).expect("parses");
802 assert_eq!(parsed.rating_text(), "1.00");
803 }
804}