1use serde::{Deserialize, Serialize};
58use serde_json::{Value, json};
59use sha2::{Digest, Sha256};
60
61use super::messages::MessageTrust;
62use super::tasks::UsageV1;
63use super::{canonical_json, require_schema, validate_hex64, validate_id, validate_text};
64use crate::{UtcTimestamp, ValidationError};
65
66pub const SWARM_SCHEMA: &str = "leanctx.hive.swarm/v1";
67pub const SWARM_SUBTASK_LIST_SCHEMA: &str = "leanctx.hive.swarm-subtask-list/v1";
68pub const SWARM_SUBTASK_PROPOSE_SCHEMA: &str = "leanctx.hive.swarm-subtask-propose/v1";
69pub const SWARM_CLAIM_REQUEST_SCHEMA: &str = "leanctx.hive.swarm-claim-request/v1";
70pub const SWARM_CLAIM_RENEW_SCHEMA: &str = "leanctx.hive.swarm-claim-renew/v1";
71pub const SWARM_CLAIM_RELEASE_SCHEMA: &str = "leanctx.hive.swarm-claim-release/v1";
72pub const SWARM_CLAIM_SCHEMA: &str = "leanctx.hive.swarm-claim/v1";
73pub const SWARM_RESULT_SCHEMA: &str = "leanctx.hive.swarm-result/v1";
74pub const SWARM_RESULT_ACK_SCHEMA: &str = "leanctx.hive.swarm-result-ack/v1";
75pub const SWARM_VERIFICATION_LIST_SCHEMA: &str = "leanctx.hive.swarm-verification-list/v1";
76pub const VERIFICATION_COMMIT_SCHEMA: &str = "leanctx.hive.verification-commit/v1";
77pub const VERIFICATION_COMMIT_V2_SCHEMA: &str = "leanctx.hive.verification-commit/v2";
80pub const VERIFICATION_REVEAL_SCHEMA: &str = "leanctx.hive.verification-reveal/v1";
81pub const VERIFICATION_REVEAL_ACK_SCHEMA: &str = "leanctx.hive.verification-reveal-ack/v1";
82pub const MODERATION_REPORT_SCHEMA: &str = "leanctx.hive.moderation-report/v1";
83pub const SWARM_SEED_SCHEMA: &str = "leanctx.hive.swarm-seed/v1";
84
85pub const MAX_RESULT_BODY_CHARS: usize = 50_000;
86pub const MAX_VALUE_TEXT_CHARS: usize = 2000;
87pub const MAX_VALUE_LIST: usize = 1000;
88pub const MAX_VALUE_ITEM_CHARS: usize = 500;
90pub const MAX_MATERIAL: usize = 64;
91pub const MAX_SEED_SUBTASKS: usize = 200;
92pub const MIN_CLAIM_MINUTES: u32 = 5;
93pub const MAX_CLAIM_MINUTES: u32 = 15;
94
95fn untrusted(trust: MessageTrust) -> Result<(), ValidationError> {
96 if trust == MessageTrust::Untrusted {
97 Ok(())
98 } else {
99 Err(ValidationError::new("every Swarm payload is untrusted"))
100 }
101}
102
103fn minutes_ok(minutes: u32) -> Result<(), ValidationError> {
104 if (MIN_CLAIM_MINUTES..=MAX_CLAIM_MINUTES).contains(&minutes) {
105 Ok(())
106 } else {
107 Err(ValidationError::new("a Swarm claim lasts 5-15 minutes"))
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct HandleRefV1 {
115 pub handle: String,
116}
117
118impl HandleRefV1 {
119 fn validate(&self) -> Result<(), ValidationError> {
120 if super::profiles::handle_ok(&self.handle) {
121 Ok(())
122 } else {
123 Err(ValidationError::new("handle is malformed"))
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum SwarmState {
131 Open,
132 Working,
133 Answered,
134 Closed,
135 #[serde(other)]
136 Unknown,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum SwarmScope {
142 Public,
143 Team,
145 #[serde(other)]
146 Unknown,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct MaterialV1 {
153 pub name: String,
154 pub media_type: String,
156 pub sha256: String,
157 pub bytes: u64,
158}
159
160impl MaterialV1 {
161 fn validate(&self) -> Result<(), ValidationError> {
162 validate_text(&self.name, 200, "material name", false)?;
163 validate_text(&self.media_type, 100, "media_type", false)?;
164 validate_hex64(&self.sha256, "material sha256")
165 }
166}
167
168fn validate_material(material: &[MaterialV1]) -> Result<(), ValidationError> {
169 if material.len() > MAX_MATERIAL {
170 return Err(ValidationError::new("at most 64 material files"));
171 }
172 material.iter().try_for_each(MaterialV1::validate)
173}
174
175fn scope_org(scope: SwarmScope, org_id: Option<&str>) -> Result<(), ValidationError> {
181 match (scope, org_id) {
182 (SwarmScope::Public, None) => Ok(()),
183 (SwarmScope::Team, Some(org)) => validate_id(org, "org_id"),
184 (SwarmScope::Public, Some(_)) => Err(ValidationError::new("a public swarm names no org")),
185 (SwarmScope::Team, None) => Err(ValidationError::new("a team swarm names its org_id")),
186 (SwarmScope::Unknown, _) => Err(ValidationError::new("unknown swarm scope")),
187 }
188}
189
190fn reaches(scope: SwarmScope, org_id: Option<&str>, lease_org_id: &str) -> bool {
194 match scope {
195 SwarmScope::Public => true,
196 SwarmScope::Team => org_id == Some(lease_org_id),
197 SwarmScope::Unknown => false,
198 }
199}
200
201fn licence_ok(licence: &str) -> bool {
202 (1..=64).contains(&licence.len())
203 && licence
204 .bytes()
205 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'+' | b'-'))
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210pub struct SwarmV1 {
211 pub schema: String,
212 pub swarm_id: String,
213 pub title: String,
214 pub body: String,
215 #[serde(default)]
216 pub topics: Vec<String>,
217 pub licence: String,
219 pub scope: SwarmScope,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub org_id: Option<String>,
223 pub state: SwarmState,
224 pub owner: HandleRefV1,
225 #[serde(default)]
226 pub material: Vec<MaterialV1>,
227 pub answer_revision: u64,
229 pub trust: MessageTrust,
230}
231
232impl SwarmV1 {
233 pub fn validate(&self) -> Result<(), ValidationError> {
234 require_schema(&self.schema, SWARM_SCHEMA)?;
235 validate_id(&self.swarm_id, "swarm_id")?;
236 validate_text(&self.title, 200, "title", false)?;
237 validate_text(&self.body, 20_000, "body", true)?;
238 if !licence_ok(&self.licence) {
239 return Err(ValidationError::new("licence is an SPDX id"));
240 }
241 scope_org(self.scope, self.org_id.as_deref())?;
242 self.owner.validate()?;
243 validate_material(&self.material)?;
244 untrusted(self.trust)
245 }
246
247 #[must_use]
249 pub fn reaches(&self, lease_org_id: &str) -> bool {
250 reaches(self.scope, self.org_id.as_deref(), lease_org_id)
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum SwarmSubtaskState {
257 Proposed,
259 Open,
260 Claimed,
261 Verifying,
262 Accepted,
263 Rejected,
264 #[serde(other)]
265 Unknown,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(rename_all = "snake_case")]
272pub enum ExpectedKind {
273 Number,
275 List,
277 Hash,
279 Text,
281 #[serde(other)]
282 Unknown,
283}
284
285pub fn validate_value(
287 kind: Option<ExpectedKind>,
288 value: Option<&Value>,
289) -> Result<(), ValidationError> {
290 let ok = match (kind, value) {
291 (None, None) => true,
292 (Some(ExpectedKind::Number), Some(v)) => v.is_i64() || v.is_u64(),
293 (Some(ExpectedKind::List), Some(v)) => v.as_array().is_some_and(|items| {
294 items.len() <= MAX_VALUE_LIST
295 && items.iter().all(|item| {
296 item.is_i64()
297 || item.is_u64()
298 || item.as_str().is_some_and(|s| {
301 validate_text(s, MAX_VALUE_ITEM_CHARS, "value", false).is_ok()
302 })
303 })
304 }),
305 (Some(ExpectedKind::Hash), Some(v)) => v
306 .as_str()
307 .is_some_and(|s| validate_hex64(s, "value").is_ok()),
308 (Some(ExpectedKind::Text), Some(v)) => v
309 .as_str()
310 .is_some_and(|s| validate_text(s, MAX_VALUE_TEXT_CHARS, "value", true).is_ok()),
311 _ => false,
312 };
313 if ok {
314 Ok(())
315 } else {
316 Err(ValidationError::new(
317 "value must match expected_kind and is absent without one",
318 ))
319 }
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct SwarmRefV1 {
326 pub swarm_id: String,
327 pub title: String,
328 pub licence: String,
329 pub scope: SwarmScope,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub org_id: Option<String>,
333}
334
335impl SwarmRefV1 {
336 pub fn validate(&self) -> Result<(), ValidationError> {
337 validate_id(&self.swarm_id, "swarm_id")?;
338 validate_text(&self.title, 200, "title", false)?;
339 if !licence_ok(&self.licence) {
340 return Err(ValidationError::new("licence is an SPDX id"));
341 }
342 scope_org(self.scope, self.org_id.as_deref())
343 }
344
345 #[must_use]
349 pub fn reaches(&self, lease_org_id: &str) -> bool {
350 reaches(self.scope, self.org_id.as_deref(), lease_org_id)
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356pub struct SwarmSubtaskV1 {
357 pub subtask_id: String,
358 pub swarm_id: String,
359 pub number: u64,
360 pub title: String,
361 pub acceptance: String,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub expected_kind: Option<ExpectedKind>,
365 pub state: SwarmSubtaskState,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub claimed_by: Option<HandleRefV1>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub claim_until: Option<UtcTimestamp>,
370 pub mine: bool,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub swarm: Option<SwarmRefV1>,
375}
376
377impl SwarmSubtaskV1 {
378 pub fn validate(&self) -> Result<(), ValidationError> {
379 validate_id(&self.subtask_id, "subtask_id")?;
380 validate_id(&self.swarm_id, "swarm_id")?;
381 validate_text(&self.title, 200, "title", false)?;
382 validate_text(&self.acceptance, 2000, "acceptance", true)?;
383 if let Some(by) = &self.claimed_by {
384 by.validate()?;
385 }
386 if self.claimed_by.is_some() != (self.state == SwarmSubtaskState::Claimed) {
387 return Err(ValidationError::new(
388 "claimed_by comes exactly with a claimed subtask",
389 ));
390 }
391 if let Some(swarm) = &self.swarm {
392 swarm.validate()?;
393 if swarm.swarm_id != self.swarm_id {
394 return Err(ValidationError::new("swarm does not match swarm_id"));
395 }
396 }
397 Ok(())
398 }
399}
400
401#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct SwarmSubtaskListV1 {
405 pub schema: String,
406 pub items: Vec<SwarmSubtaskV1>,
407 pub trust: MessageTrust,
408}
409
410impl SwarmSubtaskListV1 {
411 pub fn validate(&self) -> Result<(), ValidationError> {
412 require_schema(&self.schema, SWARM_SUBTASK_LIST_SCHEMA)?;
413 if self.items.len() > 200 {
414 return Err(ValidationError::new("at most 200 subtasks per page"));
415 }
416 self.items.iter().try_for_each(SwarmSubtaskV1::validate)?;
417 untrusted(self.trust)
418 }
419}
420
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct SwarmSubtaskProposeV1 {
425 pub schema: String,
426 pub title: String,
427 pub acceptance: String,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub expected_kind: Option<ExpectedKind>,
430}
431
432impl SwarmSubtaskProposeV1 {
433 pub fn validate(&self) -> Result<(), ValidationError> {
434 require_schema(&self.schema, SWARM_SUBTASK_PROPOSE_SCHEMA)?;
435 validate_text(&self.title, 200, "title", false)?;
436 validate_text(&self.acceptance, 2000, "acceptance", true)?;
437 if self.expected_kind == Some(ExpectedKind::Unknown) {
438 return Err(ValidationError::new("unknown expected_kind"));
439 }
440 Ok(())
441 }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(deny_unknown_fields)]
446pub struct SwarmClaimRequestV1 {
447 pub schema: String,
448 pub optin_id: String,
450 pub minutes: u32,
451}
452
453impl SwarmClaimRequestV1 {
454 pub fn validate(&self) -> Result<(), ValidationError> {
455 require_schema(&self.schema, SWARM_CLAIM_REQUEST_SCHEMA)?;
456 validate_id(&self.optin_id, "optin_id")?;
457 minutes_ok(self.minutes)
458 }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
462#[serde(deny_unknown_fields)]
463pub struct SwarmClaimRenewV1 {
464 pub schema: String,
465 pub fencing_token: u64,
466 pub minutes: u32,
467}
468
469impl SwarmClaimRenewV1 {
470 pub fn validate(&self) -> Result<(), ValidationError> {
471 require_schema(&self.schema, SWARM_CLAIM_RENEW_SCHEMA)?;
472 minutes_ok(self.minutes)
473 }
474}
475
476#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(deny_unknown_fields)]
478pub struct SwarmClaimReleaseV1 {
479 pub schema: String,
480 pub fencing_token: u64,
481}
482
483impl SwarmClaimReleaseV1 {
484 pub fn validate(&self) -> Result<(), ValidationError> {
485 require_schema(&self.schema, SWARM_CLAIM_RELEASE_SCHEMA)
486 }
487}
488
489#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491pub struct SwarmClaimV1 {
492 pub schema: String,
493 pub subtask_id: String,
494 pub swarm_id: String,
495 pub number: u64,
496 pub fencing_token: u64,
497 pub until: UtcTimestamp,
498 pub trust: MessageTrust,
499}
500
501impl SwarmClaimV1 {
502 pub fn validate(&self) -> Result<(), ValidationError> {
503 require_schema(&self.schema, SWARM_CLAIM_SCHEMA)?;
504 validate_id(&self.subtask_id, "subtask_id")?;
505 validate_id(&self.swarm_id, "swarm_id")?;
506 untrusted(self.trust)
507 }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
513#[serde(deny_unknown_fields)]
514pub struct SwarmResultRequestV1 {
515 pub schema: String,
516 pub fencing_token: u64,
517 pub summary: String,
518 #[serde(default)]
519 pub body: String,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub value: Option<Value>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub usage: Option<UsageV1>,
525}
526
527impl SwarmResultRequestV1 {
528 pub fn validate(&self) -> Result<(), ValidationError> {
531 require_schema(&self.schema, SWARM_RESULT_SCHEMA)?;
532 validate_text(&self.summary, 500, "summary", false)?;
533 if !self.body.is_empty() {
534 validate_text(&self.body, MAX_RESULT_BODY_CHARS, "body", true)?;
535 }
536 Ok(())
537 }
538
539 pub fn digest(&self) -> Result<String, ValidationError> {
542 let doc = json!({"summary": self.summary, "body": self.body, "value": self.value});
543 Ok(hex(&Sha256::digest(canonical_json(&doc)?)))
544 }
545}
546
547fn hex(bytes: &[u8]) -> String {
548 bytes.iter().map(|b| format!("{b:02x}")).collect()
549}
550
551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
552pub struct SwarmResultAckV1 {
553 pub schema: String,
554 pub result_id: String,
555 pub sha256: String,
557 pub state: SwarmSubtaskState,
558 pub verifiers_assigned: u32,
559 pub trust: MessageTrust,
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
563#[serde(rename_all = "snake_case")]
564pub enum VerificationState {
565 Assigned,
566 Committed,
567 Revealed,
568 #[serde(other)]
569 Unknown,
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct VerifiedResultV1 {
575 pub summary: String,
576 pub body: String,
577 pub by: HandleRefV1,
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
581pub struct VerifiedSubtaskV1 {
582 pub title: String,
583 pub acceptance: String,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub expected_kind: Option<ExpectedKind>,
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct SwarmVerificationV1 {
591 pub verification_id: String,
592 pub state: VerificationState,
593 pub due_at: UtcTimestamp,
595 pub result: VerifiedResultV1,
596 pub subtask: VerifiedSubtaskV1,
597 pub swarm: SwarmRefV1,
598}
599
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub struct SwarmVerificationListV1 {
602 pub schema: String,
603 pub items: Vec<SwarmVerificationV1>,
604 pub trust: MessageTrust,
605}
606
607impl SwarmVerificationListV1 {
608 pub fn validate(&self) -> Result<(), ValidationError> {
609 require_schema(&self.schema, SWARM_VERIFICATION_LIST_SCHEMA)?;
610 for item in &self.items {
611 validate_id(&item.verification_id, "verification_id")?;
612 item.result.by.validate()?;
613 item.swarm.validate()?;
614 if item.state == VerificationState::Revealed {
615 return Err(ValidationError::new("revealed assignments are not listed"));
616 }
617 }
618 untrusted(self.trust)
619 }
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(rename_all = "snake_case")]
624pub enum Verdict {
625 Agree,
626 Disagree,
627 #[serde(other)]
628 Unknown,
629}
630
631pub fn verification_commitment(
635 verdict: Verdict,
636 value: Option<&Value>,
637 nonce: &str,
638) -> Result<String, ValidationError> {
639 let doc = json!({"nonce": nonce, "value": value, "verdict": verdict});
640 Ok(hex(&Sha256::digest(canonical_json(&doc)?)))
641}
642
643pub fn verification_commitment_v2(
649 verification_id: &str,
650 verdict: Verdict,
651 value: Option<&Value>,
652 nonce: &str,
653) -> Result<String, ValidationError> {
654 let doc = json!({
655 "domain": VERIFICATION_COMMIT_V2_SCHEMA,
656 "verification_id": verification_id,
657 "nonce": nonce,
658 "value": value,
659 "verdict": verdict,
660 });
661 Ok(hex(&Sha256::digest(canonical_json(&doc)?)))
662}
663
664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct VerificationCommitV1 {
671 pub schema: String,
672 pub sha256: String,
673}
674
675impl VerificationCommitV1 {
676 pub fn validate(&self) -> Result<(), ValidationError> {
677 if self.schema != VERIFICATION_COMMIT_V2_SCHEMA {
678 require_schema(&self.schema, VERIFICATION_COMMIT_SCHEMA)?;
679 }
680 validate_hex64(&self.sha256, "sha256")
681 }
682}
683
684#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
685#[serde(deny_unknown_fields)]
686pub struct VerificationRevealV1 {
687 pub schema: String,
688 pub verdict: Verdict,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub value: Option<Value>,
692 pub nonce: String,
693 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub reason: Option<String>,
696}
697
698impl VerificationRevealV1 {
699 pub fn validate(&self) -> Result<(), ValidationError> {
700 require_schema(&self.schema, VERIFICATION_REVEAL_SCHEMA)?;
701 if self.verdict == Verdict::Unknown {
702 return Err(ValidationError::new("verdict is agree or disagree"));
703 }
704 if self.nonce.len() < 24 {
706 return Err(ValidationError::new("nonce holds at least 16 random bytes"));
707 }
708 super::validate_b64_bounded(&self.nonce, 128, "nonce")?;
709 match (&self.reason, self.verdict) {
710 (Some(reason), _) => validate_text(reason, 2000, "reason", true),
711 (None, Verdict::Disagree) => Err(ValidationError::new("disagree needs a reason")),
712 (None, _) => Ok(()),
713 }
714 }
715
716 pub fn commitment(&self) -> Result<String, ValidationError> {
717 verification_commitment(self.verdict, self.value.as_ref(), &self.nonce)
718 }
719
720 pub fn commitment_v2(&self, verification_id: &str) -> Result<String, ValidationError> {
722 verification_commitment_v2(
723 verification_id,
724 self.verdict,
725 self.value.as_ref(),
726 &self.nonce,
727 )
728 }
729}
730
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
732#[serde(rename_all = "snake_case")]
733pub enum ResultOutcome {
734 Verifying,
735 Accepted,
736 Rejected,
737 Split,
739 #[serde(other)]
740 Unknown,
741}
742
743#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
744pub struct VerificationRevealAckV1 {
745 pub schema: String,
746 pub state: VerificationState,
747 pub result_state: ResultOutcome,
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
751#[serde(rename_all = "snake_case")]
752pub enum ReportTarget {
753 Swarm,
754 Subtask,
755 Result,
756 Message,
757 #[serde(other)]
758 Unknown,
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
762#[serde(rename_all = "snake_case")]
763pub enum ReportReason {
764 Spam,
765 Abuse,
766 SecretOrPii,
767 Illegal,
768 Injection,
770 Other,
771 #[serde(other)]
772 Unknown,
773}
774
775#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
778#[serde(deny_unknown_fields)]
779pub struct ModerationReportV1 {
780 pub schema: String,
781 pub target_kind: ReportTarget,
782 pub target_id: String,
783 pub reason: ReportReason,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
785 pub note: Option<String>,
786}
787
788impl ModerationReportV1 {
789 pub fn validate(&self) -> Result<(), ValidationError> {
790 require_schema(&self.schema, MODERATION_REPORT_SCHEMA)?;
791 if self.target_kind == ReportTarget::Unknown || self.reason == ReportReason::Unknown {
792 return Err(ValidationError::new("unknown report target or reason"));
793 }
794 validate_id(&self.target_id, "target_id")?;
795 if let Some(note) = &self.note {
796 validate_text(note, 1000, "note", true)?;
797 }
798 Ok(())
799 }
800}
801
802#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
804#[serde(deny_unknown_fields)]
805pub struct SeedSubtaskV1 {
806 pub title: String,
807 pub acceptance: String,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub expected_kind: Option<ExpectedKind>,
810}
811
812#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[serde(deny_unknown_fields)]
819pub struct SwarmSeedV1 {
820 pub schema: String,
821 pub title: String,
822 pub body: String,
823 #[serde(default)]
824 pub topics: Vec<String>,
825 pub licence: String,
826 pub subtasks: Vec<SeedSubtaskV1>,
827 #[serde(default)]
828 pub material: Vec<MaterialV1>,
829}
830
831impl SwarmSeedV1 {
832 pub fn validate(&self) -> Result<(), ValidationError> {
833 require_schema(&self.schema, SWARM_SEED_SCHEMA)?;
834 validate_text(&self.title, 200, "title", false)?;
835 validate_text(&self.body, 20_000, "body", true)?;
836 if !licence_ok(&self.licence) {
837 return Err(ValidationError::new("licence is an SPDX id"));
838 }
839 if self.subtasks.is_empty() || self.subtasks.len() > MAX_SEED_SUBTASKS {
840 return Err(ValidationError::new("a seed has 1-200 subtasks"));
841 }
842 for subtask in &self.subtasks {
843 validate_text(&subtask.title, 200, "title", false)?;
844 validate_text(&subtask.acceptance, 2000, "acceptance", true)?;
845 }
846 validate_material(&self.material)
847 }
848
849 pub fn digest(&self) -> Result<String, ValidationError> {
851 let value = serde_json::to_value(self)
852 .map_err(|error| ValidationError::new(format!("seed: {error}")))?;
853 Ok(hex(&Sha256::digest(canonical_json(&value)?)))
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 #[test]
862 fn a_team_swarm_names_its_org_and_reaches_only_it() {
863 let swarm = |scope, org_id: Option<&str>| SwarmRefV1 {
864 swarm_id: "sw_01jbretry".into(),
865 title: "Retry budget".into(),
866 licence: "CC-BY-4.0".into(),
867 scope,
868 org_id: org_id.map(str::to_string),
869 };
870 let public = swarm(SwarmScope::Public, None);
871 assert!(
872 public.validate().is_ok() && public.reaches("org_acme") && public.reaches("org_other")
873 );
874 let team = swarm(SwarmScope::Team, Some("org_acme"));
875 assert!(team.validate().is_ok());
876 assert!(team.reaches("org_acme") && !team.reaches("org_other"));
877 assert!(
879 swarm(SwarmScope::Public, Some("org_acme"))
880 .validate()
881 .is_err()
882 );
883 assert!(swarm(SwarmScope::Team, None).validate().is_err());
884 let unknown = swarm(SwarmScope::Unknown, None);
885 assert!(unknown.validate().is_err() && !unknown.reaches("org_acme"));
886 }
887
888 #[test]
889 fn a_reveal_reproduces_its_commitment_and_a_changed_verdict_does_not() {
890 let reveal = VerificationRevealV1 {
891 schema: VERIFICATION_REVEAL_SCHEMA.into(),
892 verdict: Verdict::Agree,
893 value: Some(json!(3)),
894 nonce: "AAECAwQFBgcICQoLDA0ODxAREhM=".into(),
895 reason: None,
896 };
897 reveal.validate().unwrap();
898 let commitment = reveal.commitment().unwrap();
899 assert_eq!(
900 commitment,
901 verification_commitment(Verdict::Agree, Some(&json!(3)), &reveal.nonce).unwrap()
902 );
903 assert_ne!(
904 commitment,
905 verification_commitment(Verdict::Disagree, Some(&json!(3)), &reveal.nonce).unwrap()
906 );
907 }
908
909 #[test]
910 fn values_follow_the_expected_kind() {
911 assert!(validate_value(None, None).is_ok());
912 assert!(validate_value(Some(ExpectedKind::Number), Some(&json!(5))).is_ok());
913 assert!(validate_value(Some(ExpectedKind::Number), Some(&json!("5"))).is_err());
914 assert!(validate_value(None, Some(&json!(5))).is_err());
915 assert!(validate_value(Some(ExpectedKind::Hash), Some(&json!("A".repeat(64)))).is_err());
916 let list = Some(ExpectedKind::List);
918 assert!(validate_value(list, Some(&json!(["retry", 3, "backoff"]))).is_ok());
919 for bad in [
920 json!(["ok\u{E0041}\u{E0042}"]),
921 json!(["a\u{202E}b"]),
922 json!(["a\u{200B}b"]),
923 json!(["two\nlines"]),
924 json!([""]),
925 json!(["x".repeat(MAX_VALUE_ITEM_CHARS + 1)]),
926 ] {
927 assert!(validate_value(list, Some(&bad)).is_err(), "{bad}");
928 }
929 assert!(validate_value(Some(ExpectedKind::Text), Some(&json!("a\u{E0041}"))).is_err());
930 }
931}