1use rmp_serde::Deserializer;
23use rustfs_heal_contracts::heal_channel::{
24 HealAdmissionDropReason, HealAdmissionResult, HealChannelPriority, HealChannelRequest, HealChannelResponse,
25 HealRequestSource, HealScanMode,
26};
27use serde::{Deserialize, Serialize, de::SeqAccess, de::Visitor};
28use std::{fmt, io::Cursor, io::Write};
29
30const ENVELOPE_VERSION: u8 = 2;
31pub const ENVELOPE_MAX_SIZE: usize = 64 * 1024;
32pub const RESULT_MAX_SIZE: usize = 16 * 1024 * 1024;
33pub const NONCE_SIZE: usize = 16;
34pub const MAX_LIFETIME_MS: i64 = 30_000;
35const MAX_CLOCK_SKEW_MS: i64 = 5_000;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct RequestMetadata {
39 pub nonce: [u8; NONCE_SIZE],
40 pub issued_at_unix_ms: i64,
41 pub expires_at_unix_ms: i64,
42 pub coordinator_epoch: u64,
43}
44
45impl RequestMetadata {
46 pub const fn new(nonce: [u8; NONCE_SIZE], issued_at_unix_ms: i64, expires_at_unix_ms: i64, coordinator_epoch: u64) -> Self {
47 Self {
48 nonce,
49 issued_at_unix_ms,
50 expires_at_unix_ms,
51 coordinator_epoch,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58enum Priority {
59 Low,
60 Normal,
61 High,
62 Critical,
63}
64
65impl From<HealChannelPriority> for Priority {
66 fn from(priority: HealChannelPriority) -> Self {
67 match priority {
68 HealChannelPriority::Low => Self::Low,
69 HealChannelPriority::Normal => Self::Normal,
70 HealChannelPriority::High => Self::High,
71 HealChannelPriority::Critical => Self::Critical,
72 }
73 }
74}
75
76impl From<Priority> for HealChannelPriority {
77 fn from(priority: Priority) -> Self {
78 match priority {
79 Priority::Low => Self::Low,
80 Priority::Normal => Self::Normal,
81 Priority::High => Self::High,
82 Priority::Critical => Self::Critical,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase", deny_unknown_fields)]
89pub struct StartCommand {
90 disk: Option<String>,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 heal_endpoints: Vec<String>,
93 bucket: String,
94 object_prefix: Option<String>,
95 object_version_id: Option<String>,
96 force_start: bool,
97 priority: Priority,
98 pool_index: Option<u64>,
99 set_index: Option<u64>,
100 scan_mode: Option<HealScanMode>,
101 remove_corrupted: Option<bool>,
102 recreate_missing: Option<bool>,
103 update_parity: Option<bool>,
104 recursive: Option<bool>,
105 dry_run: Option<bool>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 no_lock: Option<bool>,
108 timeout_seconds: Option<u64>,
109 source: HealRequestSource,
110}
111
112impl TryFrom<HealChannelRequest> for StartCommand {
113 type Error = String;
114
115 fn try_from(request: HealChannelRequest) -> Result<Self, Self::Error> {
116 Ok(Self {
117 disk: request.disk,
118 heal_endpoints: request.heal_endpoints,
119 bucket: request.bucket,
120 object_prefix: request.object_prefix,
121 object_version_id: request.object_version_id,
122 force_start: request.force_start,
123 priority: request.priority.into(),
124 pool_index: request
125 .pool_index
126 .map(u64::try_from)
127 .transpose()
128 .map_err(|_| "heal pool index exceeds wire range".to_string())?,
129 set_index: request
130 .set_index
131 .map(u64::try_from)
132 .transpose()
133 .map_err(|_| "heal set index exceeds wire range".to_string())?,
134 scan_mode: request.scan_mode,
135 remove_corrupted: request.remove_corrupted,
136 recreate_missing: request.recreate_missing,
137 update_parity: request.update_parity,
138 recursive: request.recursive,
139 dry_run: request.dry_run,
140 no_lock: request.no_lock,
141 timeout_seconds: request.timeout_seconds,
142 source: request.source,
143 })
144 }
145}
146
147impl StartCommand {
148 fn into_channel_request(self, request_id: String) -> Result<HealChannelRequest, String> {
149 Ok(HealChannelRequest {
150 id: request_id,
151 disk: self.disk,
152 heal_endpoints: self.heal_endpoints,
153 bucket: self.bucket,
154 object_prefix: self.object_prefix,
155 object_version_id: self.object_version_id,
156 expected_bucket_incarnation_id: None,
157 force_start: self.force_start,
158 priority: self.priority.into(),
159 pool_index: self
160 .pool_index
161 .map(usize::try_from)
162 .transpose()
163 .map_err(|_| "heal pool index exceeds platform range".to_string())?,
164 set_index: self
165 .set_index
166 .map(usize::try_from)
167 .transpose()
168 .map_err(|_| "heal set index exceeds platform range".to_string())?,
169 scan_mode: self.scan_mode,
170 remove_corrupted: self.remove_corrupted,
171 recreate_missing: self.recreate_missing,
172 update_parity: self.update_parity,
173 recursive: self.recursive,
174 dry_run: self.dry_run,
175 no_lock: self.no_lock,
176 timeout_seconds: self.timeout_seconds,
177 source: self.source,
178 })
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
184pub enum Command {
185 Start {
186 request: StartCommand,
187 },
188 Query {
189 heal_path: String,
190 client_token: String,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
195 since_seq: Option<u64>,
196 },
197 Cancel {
198 heal_path: String,
199 client_token: String,
200 },
201}
202
203#[derive(Debug)]
204pub enum ExecutableCommand {
205 Start {
206 request: HealChannelRequest,
207 },
208 Query {
209 heal_path: String,
210 client_token: String,
211 since_seq: Option<u64>,
212 },
213 Cancel {
214 heal_path: String,
215 client_token: String,
216 },
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase", deny_unknown_fields)]
221pub struct Envelope {
222 version: u8,
223 request_id: String,
224 nonce: [u8; NONCE_SIZE],
225 issued_at_unix_ms: i64,
226 expires_at_unix_ms: i64,
227 coordinator_epoch: u64,
228 command: Command,
229}
230
231impl Envelope {
232 fn new(request_id: String, metadata: RequestMetadata, command: Command) -> Result<Self, String> {
233 let envelope = Self {
234 version: ENVELOPE_VERSION,
235 request_id,
236 nonce: metadata.nonce,
237 issued_at_unix_ms: metadata.issued_at_unix_ms,
238 expires_at_unix_ms: metadata.expires_at_unix_ms,
239 coordinator_epoch: metadata.coordinator_epoch,
240 command,
241 };
242 envelope.validate()?;
243 Ok(envelope)
244 }
245
246 pub fn start(request: HealChannelRequest, metadata: RequestMetadata) -> Result<Self, String> {
247 let request_id = request.id.clone();
248 Self::new(
249 request_id,
250 metadata,
251 Command::Start {
252 request: request.try_into()?,
253 },
254 )
255 }
256
257 pub fn query(
258 request_id: String,
259 metadata: RequestMetadata,
260 heal_path: String,
261 client_token: String,
262 since_seq: Option<u64>,
263 ) -> Result<Self, String> {
264 Self::new(
265 request_id,
266 metadata,
267 Command::Query {
268 heal_path,
269 client_token,
270 since_seq,
271 },
272 )
273 }
274
275 pub fn cancel(
276 request_id: String,
277 metadata: RequestMetadata,
278 heal_path: String,
279 client_token: String,
280 ) -> Result<Self, String> {
281 Self::new(request_id, metadata, Command::Cancel { heal_path, client_token })
282 }
283
284 fn validate(&self) -> Result<(), String> {
285 if self.version != ENVELOPE_VERSION {
286 return Err(format!("unsupported heal control envelope version: {}", self.version));
287 }
288 validate_uuid(&self.request_id, "request")?;
289 if self.coordinator_epoch == 0 {
290 return Err("heal control coordinator epoch is zero".to_string());
291 }
292 if self.nonce.iter().all(|byte| *byte == 0) {
293 return Err("heal control nonce is zero".to_string());
294 }
295 if self.issued_at_unix_ms < 0 || self.expires_at_unix_ms <= self.issued_at_unix_ms {
296 return Err("heal control request lifetime is invalid".to_string());
297 }
298 if self.expires_at_unix_ms - self.issued_at_unix_ms > MAX_LIFETIME_MS {
299 return Err("heal control request lifetime exceeds limit".to_string());
300 }
301 match &self.command {
302 Command::Start { .. } => {}
303 Command::Query { client_token, .. } if client_token.is_empty() => {
304 return Err("heal control client token is empty".to_string());
305 }
306 Command::Query { .. } | Command::Cancel { .. } => {}
307 }
308 Ok(())
309 }
310
311 pub fn validate_execution(&self, now_unix_ms: i64, expected_coordinator_epoch: u64) -> Result<(), String> {
312 self.validate()?;
313 if self.coordinator_epoch != expected_coordinator_epoch {
314 return Err("heal control coordinator epoch does not match".to_string());
315 }
316 if self.issued_at_unix_ms > now_unix_ms.saturating_add(MAX_CLOCK_SKEW_MS) {
317 return Err("heal control request was issued in the future".to_string());
318 }
319 if self.expires_at_unix_ms <= now_unix_ms {
320 return Err("heal control request expired".to_string());
321 }
322 Ok(())
323 }
324
325 pub fn into_execution(self) -> Result<(String, u64, ExecutableCommand), String> {
326 let command = match self.command {
327 Command::Start { request } => ExecutableCommand::Start {
328 request: request.into_channel_request(self.request_id.clone())?,
329 },
330 Command::Query {
331 heal_path,
332 client_token,
333 since_seq,
334 } => ExecutableCommand::Query {
335 heal_path,
336 client_token,
337 since_seq,
338 },
339 Command::Cancel { heal_path, client_token } => ExecutableCommand::Cancel { heal_path, client_token },
340 };
341 Ok((self.request_id, self.coordinator_epoch, command))
342 }
343
344 pub const fn expires_at_unix_ms(&self) -> i64 {
345 self.expires_at_unix_ms
346 }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum Admission {
352 Accepted,
353 Merged,
354 Full,
355 DroppedQueueFull,
356 DroppedPolicy,
357 DroppedAlreadyRunning,
360 DroppedOverlappingPaths,
362}
363
364impl From<HealAdmissionResult> for Admission {
365 fn from(result: HealAdmissionResult) -> Self {
366 match result {
367 HealAdmissionResult::Accepted => Self::Accepted,
368 HealAdmissionResult::Merged => Self::Merged,
369 HealAdmissionResult::Full => Self::Full,
370 HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull) => Self::DroppedQueueFull,
371 HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped) => Self::DroppedPolicy,
372 HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning) => Self::DroppedAlreadyRunning,
373 HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths) => Self::DroppedOverlappingPaths,
374 }
375 }
376}
377
378impl Admission {
379 pub const fn is_admitted(self) -> bool {
380 matches!(self, Self::Accepted | Self::Merged)
381 }
382
383 pub const fn into_heal_admission_result(self) -> HealAdmissionResult {
384 match self {
385 Self::Accepted => HealAdmissionResult::Accepted,
386 Self::Merged => HealAdmissionResult::Merged,
387 Self::Full => HealAdmissionResult::Full,
388 Self::DroppedQueueFull => HealAdmissionResult::Dropped(HealAdmissionDropReason::QueueFull),
389 Self::DroppedPolicy => HealAdmissionResult::Dropped(HealAdmissionDropReason::PolicyDropped),
390 Self::DroppedAlreadyRunning => HealAdmissionResult::Dropped(HealAdmissionDropReason::AlreadyRunning),
391 Self::DroppedOverlappingPaths => HealAdmissionResult::Dropped(HealAdmissionDropReason::OverlappingPaths),
392 }
393 }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(tag = "result", rename_all = "snake_case", deny_unknown_fields)]
398pub enum Outcome {
399 Start {
400 task_id: String,
401 admission: Admission,
402 },
403 Channel {
404 success: bool,
405 #[serde(deserialize_with = "deserialize_bounded_optional_bytes")]
406 data: Option<Vec<u8>>,
407 error: Option<String>,
408 },
409}
410
411#[derive(Debug)]
412struct BoundedBytes(Vec<u8>);
413struct BoundedBytesVisitor;
414
415impl<'de> Visitor<'de> for BoundedBytesVisitor {
416 type Value = BoundedBytes;
417
418 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
419 write!(formatter, "at most {RESULT_MAX_SIZE} bytes")
420 }
421
422 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
423 where
424 A: SeqAccess<'de>,
425 {
426 if sequence.size_hint().is_some_and(|length| length > RESULT_MAX_SIZE) {
427 return Err(serde::de::Error::custom("heal control response data exceeds size limit"));
428 }
429
430 let mut bytes = Vec::new();
431 while let Some(byte) = sequence.next_element()? {
432 if bytes.len() == RESULT_MAX_SIZE {
433 return Err(serde::de::Error::custom("heal control response data exceeds size limit"));
434 }
435 bytes.push(byte);
436 }
437 Ok(BoundedBytes(bytes))
438 }
439}
440
441impl<'de> Deserialize<'de> for BoundedBytes {
442 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
443 where
444 D: serde::Deserializer<'de>,
445 {
446 deserializer.deserialize_seq(BoundedBytesVisitor)
447 }
448}
449
450fn deserialize_bounded_optional_bytes<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
451where
452 D: serde::Deserializer<'de>,
453{
454 Ok(Option::<BoundedBytes>::deserialize(deserializer)?.map(|bytes| bytes.0))
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(rename_all = "camelCase", deny_unknown_fields)]
459pub struct ResultEnvelope {
460 version: u8,
461 request_id: String,
462 coordinator_epoch: u64,
463 outcome: Outcome,
464}
465
466impl ResultEnvelope {
467 pub fn new(request_id: String, coordinator_epoch: u64, outcome: Outcome) -> Result<Self, String> {
468 let result = Self {
469 version: ENVELOPE_VERSION,
470 request_id,
471 coordinator_epoch,
472 outcome,
473 };
474 result.validate()?;
475 Ok(result)
476 }
477
478 pub fn channel(request_id: String, coordinator_epoch: u64, response: HealChannelResponse) -> Result<Self, String> {
479 if response.request_id != request_id {
480 return Err("heal control channel response request ID does not match".to_string());
481 }
482 Self::new(
483 request_id,
484 coordinator_epoch,
485 Outcome::Channel {
486 success: response.success,
487 data: response.data,
488 error: response.error,
489 },
490 )
491 }
492
493 fn validate(&self) -> Result<(), String> {
494 if self.version != ENVELOPE_VERSION {
495 return Err(format!("unsupported heal control result version: {}", self.version));
496 }
497 if self.coordinator_epoch == 0 {
498 return Err("heal control result coordinator epoch is zero".to_string());
499 }
500 validate_uuid(&self.request_id, "result request")?;
501 match &self.outcome {
502 Outcome::Start { task_id, .. } => validate_uuid(task_id, "result task")?,
503 Outcome::Channel {
504 success: false,
505 error: None,
506 ..
507 } => {
508 return Err("failed heal control result has no error".to_string());
509 }
510 Outcome::Channel {
511 success: false,
512 data: Some(_),
513 ..
514 } => {
515 return Err("failed heal control result contains data".to_string());
516 }
517 Outcome::Channel {
518 success: false,
519 error: Some(error),
520 ..
521 } if error.is_empty() => {
522 return Err("failed heal control result has an empty error".to_string());
523 }
524 Outcome::Channel { .. } => {}
525 }
526 Ok(())
527 }
528
529 pub fn into_outcome(self, expected_request_id: &str, expected_epoch: u64) -> Result<Outcome, String> {
530 self.validate()?;
531 if self.request_id != expected_request_id {
532 return Err("heal control result request ID does not match".to_string());
533 }
534 if self.coordinator_epoch != expected_epoch {
535 return Err("heal control result coordinator epoch does not match".to_string());
536 }
537 Ok(self.outcome)
538 }
539}
540
541fn validate_uuid(value: &str, field: &str) -> Result<(), String> {
542 let parsed = uuid::Uuid::parse_str(value).map_err(|_| format!("heal control {field} ID is not a UUID"))?;
543 if parsed.is_nil() || parsed.to_string() != value {
544 return Err(format!("heal control {field} ID is not canonical"));
545 }
546 Ok(())
547}
548
549fn decode<T: for<'de> Deserialize<'de>>(data: &[u8], value_name: &str, max_size: usize) -> Result<T, String> {
550 if data.len() > max_size {
551 return Err(format!("{value_name} exceeds size limit"));
552 }
553 let mut deserializer = Deserializer::new(Cursor::new(data));
554 let value = T::deserialize(&mut deserializer).map_err(|err| format!("failed to decode {value_name}: {err}"))?;
555 if usize::try_from(deserializer.get_ref().position()).ok() != Some(data.len()) {
556 return Err(format!("{value_name} contains trailing data"));
557 }
558 Ok(value)
559}
560
561pub fn encode_envelope(envelope: &Envelope) -> Result<Vec<u8>, String> {
562 envelope.validate()?;
563 encode_bounded(envelope, "heal control envelope", ENVELOPE_MAX_SIZE)
564}
565
566pub fn decode_envelope(data: &[u8]) -> Result<Envelope, String> {
567 let envelope: Envelope = decode(data, "heal control envelope", ENVELOPE_MAX_SIZE)?;
568 envelope.validate()?;
569 Ok(envelope)
570}
571
572pub fn encode_result(result: &ResultEnvelope) -> Result<Vec<u8>, String> {
573 result.validate()?;
574 encode_bounded(result, "heal control result", RESULT_MAX_SIZE)
575}
576
577pub fn decode_result(data: &[u8]) -> Result<ResultEnvelope, String> {
578 let result: ResultEnvelope = decode(data, "heal control result", RESULT_MAX_SIZE)?;
579 result.validate()?;
580 Ok(result)
581}
582
583fn encode_bounded(value: &impl Serialize, value_name: &str, max_size: usize) -> Result<Vec<u8>, String> {
584 struct BoundedWriter {
585 bytes: Vec<u8>,
586 exceeded: bool,
587 max_size: usize,
588 }
589
590 impl Write for BoundedWriter {
591 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
592 let remaining = self.max_size.saturating_sub(self.bytes.len());
593 if data.len() > remaining {
594 self.exceeded = true;
595 return Err(std::io::Error::other("heal control value exceeds size limit"));
596 }
597 self.bytes.extend_from_slice(data);
598 Ok(data.len())
599 }
600
601 fn flush(&mut self) -> std::io::Result<()> {
602 Ok(())
603 }
604 }
605
606 let mut writer = BoundedWriter {
607 bytes: Vec::with_capacity(1024),
608 exceeded: false,
609 max_size,
610 };
611 let result = value.serialize(&mut rmp_serde::Serializer::new(&mut writer).with_struct_map());
612 if writer.exceeded {
613 return Err(format!("{value_name} exceeds size limit"));
614 }
615 result.map_err(|err| format!("failed to encode {value_name}: {err}"))?;
616 Ok(writer.bytes)
617}
618
619#[cfg(test)]
620mod tests {
621 use super::{
622 Admission, ENVELOPE_MAX_SIZE, Envelope, ExecutableCommand, Outcome, RESULT_MAX_SIZE, RequestMetadata, ResultEnvelope,
623 decode_envelope, decode_result, encode_result,
624 };
625 use rustfs_heal_contracts::heal_channel::{HealChannelRequest, HealChannelResponse, HealRequestSource};
626 use serde::de::{DeserializeSeed, SeqAccess, Visitor, value::Error as ValueError};
627
628 fn test_request(request_id: String) -> HealChannelRequest {
629 HealChannelRequest {
630 id: request_id,
631 bucket: "bucket".to_string(),
632 object_prefix: Some("prefix".to_string()),
633 pool_index: Some(1),
634 set_index: Some(2),
635 source: HealRequestSource::Admin,
636 ..Default::default()
637 }
638 }
639
640 fn replacement_test_request(request_id: String) -> HealChannelRequest {
641 let mut request = test_request(request_id);
642 request.heal_endpoints = vec!["http://node1:9000/drive2".to_string()];
643 request
644 }
645
646 fn metadata(byte: u8, epoch: u64) -> RequestMetadata {
647 RequestMetadata::new([byte; 16], 1_000, 2_000, epoch)
648 }
649
650 #[test]
651 fn round_trips_all_commands_and_results() {
652 let request_id = uuid::Uuid::new_v4().to_string();
653 let start = Envelope::start(replacement_test_request(request_id), metadata(1, 7)).unwrap();
654 let query = Envelope::query(
655 uuid::Uuid::new_v4().to_string(),
656 metadata(2, 7),
657 "bucket/prefix".to_string(),
658 "token".to_string(),
659 None,
660 )
661 .unwrap();
662 let cancel = Envelope::cancel(
663 uuid::Uuid::new_v4().to_string(),
664 metadata(3, 7),
665 "bucket/prefix".to_string(),
666 "token".to_string(),
667 )
668 .unwrap();
669
670 for envelope in [start, query, cancel] {
671 let encoded = super::encode_envelope(&envelope).unwrap();
672 assert_eq!(decode_envelope(&encoded).unwrap(), envelope);
673 }
674
675 let result = ResultEnvelope::new(
676 uuid::Uuid::new_v4().to_string(),
677 7,
678 Outcome::Start {
679 task_id: uuid::Uuid::new_v4().to_string(),
680 admission: Admission::Merged,
681 },
682 )
683 .unwrap();
684 let encoded = super::encode_result(&result).unwrap();
685 assert_eq!(decode_result(&encoded).unwrap(), result);
686
687 let channel_request_id = uuid::Uuid::new_v4().to_string();
688 let channel = ResultEnvelope::channel(
689 channel_request_id.clone(),
690 7,
691 HealChannelResponse {
692 request_id: channel_request_id,
693 success: true,
694 data: Some(vec![1, 2, 3]),
695 error: None,
696 },
697 )
698 .unwrap();
699 let encoded = super::encode_result(&channel).unwrap();
700 assert_eq!(decode_result(&encoded).unwrap(), channel);
701 }
702
703 #[test]
704 fn v2_start_round_trips_no_lock() {
705 for no_lock in [None, Some(false), Some(true)] {
706 let request_id = uuid::Uuid::new_v4().to_string();
707 let mut request = test_request(request_id);
708 request.no_lock = no_lock;
709
710 let envelope = Envelope::start(request, metadata(1, 7)).expect("v2 start should encode nolock");
711 let encoded = super::encode_envelope(&envelope).expect("v2 start should serialize");
712 let decoded = super::decode_envelope(&encoded).expect("v2 start should deserialize");
713 let (_, _, ExecutableCommand::Start { request }) = decoded.into_execution().expect("v2 start command should decode")
714 else {
715 panic!("expected start command");
716 };
717
718 assert_eq!(request.no_lock, no_lock);
719 }
720 }
721
722 #[test]
723 fn v2_command_and_result_wire_fixtures_are_stable() {
724 let request_id = "00112233-4455-6677-8899-aabbccddeeff".to_string();
725 let start = Envelope::start(
726 test_request(request_id.clone()),
727 RequestMetadata::new([0x11; 16], 1_700_000_000_000, 1_700_000_030_000, 9),
728 )
729 .unwrap();
730 let envelope = Envelope::query(
731 request_id.clone(),
732 RequestMetadata::new([0x11; 16], 1_700_000_000_000, 1_700_000_030_000, 9),
733 "bucket/prefix".to_string(),
734 "client-token".to_string(),
735 None,
736 )
737 .unwrap();
738 let cancel = Envelope::cancel(
739 request_id.clone(),
740 RequestMetadata::new([0x11; 16], 1_700_000_000_000, 1_700_000_030_000, 9),
741 "bucket/prefix".to_string(),
742 "client-token".to_string(),
743 )
744 .unwrap();
745 let start_result = ResultEnvelope::new(
746 request_id.clone(),
747 9,
748 Outcome::Start {
749 task_id: "ffeeddcc-bbaa-9988-7766-554433221100".to_string(),
750 admission: Admission::DroppedPolicy,
751 },
752 )
753 .unwrap();
754 let result = ResultEnvelope::new(
755 request_id,
756 9,
757 Outcome::Channel {
758 success: true,
759 data: Some(vec![1, 2, 3]),
760 error: None,
761 },
762 )
763 .unwrap();
764
765 let envelope_hex = super::encode_envelope(&envelope)
766 .unwrap()
767 .iter()
768 .map(|byte| format!("{byte:02x}"))
769 .collect::<String>();
770 let result_hex = super::encode_result(&result)
771 .unwrap()
772 .iter()
773 .map(|byte| format!("{byte:02x}"))
774 .collect::<String>();
775 let start_hex = super::encode_envelope(&start)
776 .unwrap()
777 .iter()
778 .map(|byte| format!("{byte:02x}"))
779 .collect::<String>();
780 let cancel_hex = super::encode_envelope(&cancel)
781 .unwrap()
782 .iter()
783 .map(|byte| format!("{byte:02x}"))
784 .collect::<String>();
785 let start_result_hex = super::encode_result(&start_result)
786 .unwrap()
787 .iter()
788 .map(|byte| format!("{byte:02x}"))
789 .collect::<String>();
790 assert_eq!(
791 start_hex,
792 "87a776657273696f6e02a9726571756573744964d92430303131323233332d343435352d363637372d383839392d616162626363646465656666a56e6f6e6365dc001011111111111111111111111111111111ae6973737565644174556e69784d73cf0000018bcfe56800af657870697265734174556e69784d73cf0000018bcfe5dd30b0636f6f7264696e61746f7245706f636809a7636f6d6d616e6482a6616374696f6ea57374617274a772657175657374de0010a46469736bc0a66275636b6574a66275636b6574ac6f626a656374507265666978a6707265666978af6f626a65637456657273696f6e4964c0aa666f7263655374617274c2a87072696f72697479a66e6f726d616ca9706f6f6c496e64657801a8736574496e64657802a87363616e4d6f6465c0af72656d6f7665436f72727570746564c0af72656372656174654d697373696e67c0ac757064617465506172697479c0a9726563757273697665c0a664727952756ec0ae74696d656f75745365636f6e6473c0a6736f75726365a561646d696e"
793 );
794 assert_eq!(
795 cancel_hex,
796 "87a776657273696f6e02a9726571756573744964d92430303131323233332d343435352d363637372d383839392d616162626363646465656666a56e6f6e6365dc001011111111111111111111111111111111ae6973737565644174556e69784d73cf0000018bcfe56800af657870697265734174556e69784d73cf0000018bcfe5dd30b0636f6f7264696e61746f7245706f636809a7636f6d6d616e6483a6616374696f6ea663616e63656ca96865616c5f70617468ad6275636b65742f707265666978ac636c69656e745f746f6b656eac636c69656e742d746f6b656e"
797 );
798 assert_eq!(
799 start_result_hex,
800 "84a776657273696f6e02a9726571756573744964d92430303131323233332d343435352d363637372d383839392d616162626363646465656666b0636f6f7264696e61746f7245706f636809a76f7574636f6d6583a6726573756c74a57374617274a77461736b5f6964d92466666565646463632d626261612d393938382d373736362d353534343333323231313030a961646d697373696f6eae64726f707065645f706f6c696379"
801 );
802 assert_eq!(
803 envelope_hex,
804 "87a776657273696f6e02a9726571756573744964d92430303131323233332d343435352d363637372d383839392d616162626363646465656666a56e6f6e6365dc001011111111111111111111111111111111ae6973737565644174556e69784d73cf0000018bcfe56800af657870697265734174556e69784d73cf0000018bcfe5dd30b0636f6f7264696e61746f7245706f636809a7636f6d6d616e6483a6616374696f6ea57175657279a96865616c5f70617468ad6275636b65742f707265666978ac636c69656e745f746f6b656eac636c69656e742d746f6b656e"
805 );
806 assert_eq!(
807 result_hex,
808 "84a776657273696f6e02a9726571756573744964d92430303131323233332d343435352d363637372d383839392d616162626363646465656666b0636f6f7264696e61746f7245706f636809a76f7574636f6d6584a6726573756c74a76368616e6e656ca773756363657373c3a46461746193010203a56572726f72c0"
809 );
810 }
811
812 #[test]
813 fn rejects_invalid_and_untrusted_shapes() {
814 let request_id = uuid::Uuid::new_v4().to_string();
815 assert!(Envelope::start(test_request(request_id.clone()), metadata(0, 7)).is_err());
816 assert!(Envelope::start(test_request(request_id.clone()), metadata(1, 0)).is_err());
817 assert!(Envelope::start(test_request(request_id.clone()), RequestMetadata::new([1; 16], 1_000, 31_001, 7),).is_err());
818 assert!(Envelope::query(request_id.clone(), metadata(1, 7), String::new(), String::new(), None).is_err());
819 assert!(Envelope::cancel(request_id.clone(), metadata(1, 7), String::new(), String::new()).is_ok());
820
821 let mut noncanonical_request = test_request(request_id.to_uppercase());
822 assert!(Envelope::start(noncanonical_request.clone(), metadata(1, 7)).is_err());
823 noncanonical_request.id = uuid::Uuid::nil().to_string();
824 assert!(Envelope::start(noncanonical_request, metadata(1, 7)).is_err());
825
826 let boundary = RequestMetadata::new([1; 16], 1_000, 1_000 + super::MAX_LIFETIME_MS, 7);
827 assert!(Envelope::start(test_request(request_id.clone()), boundary).is_ok());
828 assert!(
829 Envelope::start(
830 test_request(request_id.clone()),
831 RequestMetadata::new([1; 16], 1_000, 1_001 + super::MAX_LIFETIME_MS, 7),
832 )
833 .is_err()
834 );
835
836 let envelope = Envelope::start(test_request(request_id.clone()), metadata(1, 7)).unwrap();
837 let mut trailing = super::encode_envelope(&envelope).unwrap();
838 trailing.push(0);
839 assert!(decode_envelope(&trailing).unwrap_err().contains("trailing data"));
840 assert!(
841 decode_envelope(&vec![0; ENVELOPE_MAX_SIZE + 1])
842 .unwrap_err()
843 .contains("size limit")
844 );
845
846 let oversized = Envelope::query(
847 uuid::Uuid::new_v4().to_string(),
848 metadata(1, 7),
849 "x".repeat(ENVELOPE_MAX_SIZE),
850 "token".to_string(),
851 None,
852 )
853 .unwrap();
854 let error = super::encode_envelope(&oversized).unwrap_err();
855 assert!(error.contains("size limit"), "{error}");
856
857 let mut unknown = serde_json::to_value(&envelope).unwrap();
858 unknown
859 .as_object_mut()
860 .unwrap()
861 .insert("unknown".to_string(), serde_json::Value::Bool(true));
862 let unknown = rmp_serde::to_vec_named(&unknown).unwrap();
863 assert!(decode_envelope(&unknown).unwrap_err().contains("unknown field"));
864
865 let mut read_repair = serde_json::to_value(&envelope).expect("start envelope should serialize");
866 read_repair["command"]["request"]
867 .as_object_mut()
868 .expect("start request must be an object")
869 .insert("readRepair".to_string(), serde_json::Value::Bool(true));
870 let encoded = rmp_serde::to_vec_named(&read_repair).expect("invalid start fixture should encode");
871 let error = decode_envelope(&encoded).expect_err("RPC must reject an Admin readRepair field");
872 assert!(error.contains("unknown field") && error.contains("readRepair"), "{error}");
873
874 let executable =
875 Envelope::start(test_request(request_id.clone()), RequestMetadata::new([1; 16], 10_000, 20_000, 7)).unwrap();
876 assert!(executable.validate_execution(15_000, 7).is_ok());
877 assert!(executable.validate_execution(20_000, 7).unwrap_err().contains("expired"));
878 assert!(executable.validate_execution(4_999, 7).unwrap_err().contains("future"));
879 let wrong_epoch =
880 Envelope::start(test_request(request_id.clone()), RequestMetadata::new([1; 16], 10_000, 20_000, 8)).unwrap();
881 assert!(wrong_epoch.validate_execution(15_000, 7).unwrap_err().contains("epoch"));
882
883 assert!(
884 ResultEnvelope::channel(
885 uuid::Uuid::new_v4().to_string(),
886 7,
887 HealChannelResponse {
888 request_id: uuid::Uuid::new_v4().to_string(),
889 success: true,
890 data: None,
891 error: None,
892 },
893 )
894 .unwrap_err()
895 .contains("does not match")
896 );
897
898 assert!(
899 ResultEnvelope::new(
900 request_id.clone(),
901 7,
902 Outcome::Channel {
903 success: true,
904 data: None,
905 error: Some("status detail".to_string()),
906 },
907 )
908 .is_ok()
909 );
910
911 let large_result = ResultEnvelope::new(
912 request_id.clone(),
913 7,
914 Outcome::Channel {
915 success: true,
916 data: Some(vec![7; ENVELOPE_MAX_SIZE + 1]),
917 error: None,
918 },
919 )
920 .unwrap();
921 let large_result = encode_result(&large_result).expect("status results may exceed the request envelope limit");
922 assert!(large_result.len() > ENVELOPE_MAX_SIZE);
923 assert!(decode_result(&large_result).is_ok());
924 assert!(
925 ResultEnvelope::new(
926 request_id.clone(),
927 7,
928 Outcome::Channel {
929 success: false,
930 data: None,
931 error: None,
932 },
933 )
934 .unwrap_err()
935 .contains("has no error")
936 );
937 assert!(
938 ResultEnvelope::new(
939 request_id.clone(),
940 0,
941 Outcome::Channel {
942 success: true,
943 data: None,
944 error: None,
945 },
946 )
947 .unwrap_err()
948 .contains("epoch is zero")
949 );
950 assert!(
951 ResultEnvelope::new(
952 "not-a-uuid".to_string(),
953 7,
954 Outcome::Channel {
955 success: true,
956 data: None,
957 error: None,
958 },
959 )
960 .unwrap_err()
961 .contains("not a UUID")
962 );
963 assert!(
964 ResultEnvelope::new(
965 request_id.clone(),
966 7,
967 Outcome::Start {
968 task_id: "not-a-uuid".to_string(),
969 admission: Admission::Accepted,
970 },
971 )
972 .unwrap_err()
973 .contains("not a UUID")
974 );
975 assert!(
976 ResultEnvelope::new(
977 request_id.clone(),
978 7,
979 Outcome::Channel {
980 success: false,
981 data: Some(vec![1]),
982 error: Some("failed".to_string()),
983 },
984 )
985 .is_err()
986 );
987 assert!(
988 ResultEnvelope::new(
989 request_id.clone(),
990 7,
991 Outcome::Channel {
992 success: false,
993 data: None,
994 error: Some(String::new()),
995 },
996 )
997 .is_err()
998 );
999
1000 let valid_result = ResultEnvelope::new(
1001 request_id,
1002 7,
1003 Outcome::Channel {
1004 success: true,
1005 data: Some(vec![1]),
1006 error: None,
1007 },
1008 )
1009 .unwrap();
1010 let mut length_bomb = rmp_serde::to_vec_named(&valid_result).unwrap();
1011 let data_key = length_bomb
1012 .windows(6)
1013 .position(|window| window == b"\xa4data\x91")
1014 .expect("fixture must encode data as a one-element array");
1015 length_bomb.splice(data_key + 5..=data_key + 5, [0xdd, 0xff, 0xff, 0xff, 0xff]);
1016 assert!(decode_result(&length_bomb).is_err());
1017
1018 struct OversizedHint;
1019
1020 impl<'de> SeqAccess<'de> for OversizedHint {
1021 type Error = ValueError;
1022
1023 fn next_element_seed<T>(&mut self, _seed: T) -> Result<Option<T::Value>, Self::Error>
1024 where
1025 T: DeserializeSeed<'de>,
1026 {
1027 Ok(None)
1028 }
1029
1030 fn size_hint(&self) -> Option<usize> {
1031 Some(RESULT_MAX_SIZE + 1)
1032 }
1033 }
1034
1035 let error = super::BoundedBytesVisitor.visit_seq(OversizedHint).unwrap_err();
1036 assert!(error.to_string().contains("size limit"));
1037 }
1038}