1use crate::mount::NFSVersion;
18use thiserror::Error;
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum OperationOutcome {
23 DefiniteFailure,
25 SafeToRetry,
27 Uncertain,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum OperationClass {
34 ReadOnly,
35 SessionControl,
36 ReplaySensitive,
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum RecoveryAction {
42 Retry,
43 Reopen,
44 Remount,
45 VerifyThenResume,
46 DoNotRetry,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum RequestTransmission {
52 NotSent,
54 Sent,
56}
57
58#[derive(Debug)]
59struct TransportFailure {
60 transmission: RequestTransmission,
61 source: Box<NfsError>,
62}
63
64impl std::fmt::Display for TransportFailure {
65 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 self.source.fmt(formatter)
67 }
68}
69
70impl std::error::Error for TransportFailure {
71 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
72 Some(&*self.source)
73 }
74}
75
76#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct RequestId {
79 kind: RequestIdKind,
80}
81
82#[derive(Clone, Debug, Eq, PartialEq)]
83enum RequestIdKind {
84 #[allow(dead_code)]
85 Nfs40Owner { owner: u64, sequence_id: u32 },
86 Nfs41Session {
87 session_id: [u8; 16],
88 slot_id: u32,
89 sequence_id: u32,
90 },
91}
92
93impl RequestId {
94 pub(crate) fn nfs41(session_id: [u8; 16], slot_id: u32, sequence_id: u32) -> Self {
95 Self {
96 kind: RequestIdKind::Nfs41Session {
97 session_id,
98 slot_id,
99 sequence_id,
100 },
101 }
102 }
103
104 #[allow(dead_code)]
105 pub(crate) fn nfs40(owner: u64, sequence_id: u32) -> Self {
106 Self {
107 kind: RequestIdKind::Nfs40Owner { owner, sequence_id },
108 }
109 }
110}
111
112#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct RequestContext {
115 pub operation: String,
116 pub protocol: NFSVersion,
117 pub request_id: Option<RequestId>,
118}
119
120#[derive(Error, Debug)]
122#[error("{outcome:?} NFS operation {operation_class:?}; recovery={recovery:?}")]
123pub struct OperationOutcomeError {
124 pub outcome: OperationOutcome,
125 pub operation_class: OperationClass,
126 pub transmission: RequestTransmission,
127 pub recovery: RecoveryAction,
128 pub completed_bytes: Option<u64>,
129 #[source]
130 pub source: Box<NfsError>,
131 context: RequestContext,
132}
133
134impl OperationOutcomeError {
135 pub fn new(
136 outcome: OperationOutcome,
137 operation_class: OperationClass,
138 recovery: RecoveryAction,
139 context: RequestContext,
140 source: NfsError,
141 ) -> Self {
142 Self {
143 outcome,
144 operation_class,
145 transmission: RequestTransmission::Sent,
146 recovery,
147 completed_bytes: None,
148 source: Box::new(source),
149 context,
150 }
151 }
152
153 pub fn context(&self) -> &RequestContext {
154 &self.context
155 }
156
157 pub fn with_completed_bytes(mut self, completed_bytes: u64) -> Self {
160 self.completed_bytes = Some(completed_bytes);
161 self
162 }
163}
164
165#[derive(Error, Debug)]
170pub enum NfsError {
171 #[error("{0}")]
173 Io(#[from] std::io::Error),
174
175 #[error("NFS3 error: {0}")]
177 Nfs3(crate::nfs3::ErrorCode),
178
179 #[error("NFS4 error: {0}")]
181 Nfs4(crate::nfs4::Nfs4ErrorCode),
182
183 #[error("NFS4 lock denied: type {lock_type}, offset {offset}, length {length}")]
185 LockDenied {
186 lock_type: u32,
187 offset: u64,
188 length: u64,
189 owner: bytes::Bytes,
190 },
191
192 #[error("Mount error: {0}")]
194 Mount(crate::nfs3::MountErrorCode),
195
196 #[error("RPC error: {0}")]
198 Rpc(String),
199
200 #[error("XDR error: {0}")]
202 Xdr(String),
203
204 #[error("{0}")]
206 Unsupported(String),
207
208 #[error("{0}")]
210 InvalidInput(String),
211
212 #[error("{0}")]
214 ClosedResource(String),
215
216 #[error("{0}")]
218 ModeViolation(String),
219
220 #[error("{0}")]
222 ClientClosed(String),
223
224 #[error("{0}")]
226 PositionUncertain(String),
227
228 #[error("{0}")]
230 LostOpenState(String),
231
232 #[error("file close completed with errors")]
234 FileClose(Vec<FileCloseFailure>),
235
236 #[error("rdattr_error: server returned nfsstat4 {0} for entry attributes")]
238 RdattrError(u32),
239
240 #[error(transparent)]
242 OperationOutcome(#[from] Box<OperationOutcomeError>),
243}
244
245#[derive(Debug)]
247pub struct FileCloseFailure {
248 pub operation: &'static str,
250 pub error: std::sync::Arc<NfsError>,
252}
253
254impl NfsError {
255 pub(crate) fn transport(transmission: RequestTransmission, source: NfsError) -> Self {
256 let kind = source.kind();
257 Self::Io(std::io::Error::new(
258 kind,
259 TransportFailure {
260 transmission,
261 source: Box::new(source),
262 },
263 ))
264 }
265
266 pub fn request_transmission(&self) -> Option<RequestTransmission> {
268 match self {
269 Self::Io(error) => error
270 .get_ref()
271 .and_then(|source| source.downcast_ref::<TransportFailure>())
272 .map(|failure| failure.transmission),
273 Self::OperationOutcome(error) => Some(error.transmission),
274 _ => None,
275 }
276 }
277
278 pub fn before_send_failure(
281 operation_class: OperationClass,
282 context: RequestContext,
283 completed_bytes: Option<u64>,
284 source: NfsError,
285 ) -> Self {
286 let mut error = OperationOutcomeError::new(
287 OperationOutcome::DefiniteFailure,
288 operation_class,
289 RecoveryAction::Retry,
290 context,
291 source,
292 );
293 error.transmission = RequestTransmission::NotSent;
294 error.completed_bytes = completed_bytes;
295 Self::OperationOutcome(Box::new(error))
296 }
297
298 pub fn operation_outcome(&self) -> Option<&OperationOutcomeError> {
300 match self {
301 Self::OperationOutcome(error) => Some(error),
302 _ => None,
303 }
304 }
305
306 pub fn is_exist(&self) -> bool {
308 matches!(
309 self,
310 NfsError::Nfs3(crate::nfs3::ErrorCode::NFS3ERR_EXIST)
311 | NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_EXIST)
312 )
313 }
314
315 pub fn is_not_found(&self) -> bool {
317 matches!(
318 self,
319 NfsError::Nfs3(crate::nfs3::ErrorCode::NFS3ERR_NOENT)
320 | NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_NOENT)
321 )
322 }
323
324 pub fn kind(&self) -> std::io::ErrorKind {
327 match self {
328 NfsError::Io(io) => io.kind(),
329 NfsError::Nfs3(_) => std::io::ErrorKind::Other,
330 NfsError::Nfs4(_) => std::io::ErrorKind::Other,
331 NfsError::LockDenied { .. } => std::io::ErrorKind::WouldBlock,
332 NfsError::Mount(_) => std::io::ErrorKind::Other,
333 NfsError::Rpc(_) => std::io::ErrorKind::Other,
334 NfsError::Xdr(_) => std::io::ErrorKind::Other,
335 NfsError::Unsupported(_) => std::io::ErrorKind::Unsupported,
336 NfsError::InvalidInput(_) => std::io::ErrorKind::InvalidInput,
337 NfsError::ClosedResource(_) => std::io::ErrorKind::NotConnected,
338 NfsError::ModeViolation(_) => std::io::ErrorKind::PermissionDenied,
339 NfsError::ClientClosed(_) => std::io::ErrorKind::NotConnected,
340 NfsError::PositionUncertain(_)
341 | NfsError::LostOpenState(_)
342 | NfsError::FileClose(_) => std::io::ErrorKind::Other,
343 NfsError::RdattrError(_) => std::io::ErrorKind::Other,
344 NfsError::OperationOutcome(_) => std::io::ErrorKind::Other,
345 }
346 }
347}
348
349pub(crate) fn classify_sent_nfs41_error(
350 operation_class: OperationClass,
351 context: RequestContext,
352 source: NfsError,
353) -> NfsError {
354 let replay_protocol_error = matches!(
355 &source,
356 NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_RETRY_UNCACHED_REP)
357 | NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_SEQ_FALSE_RETRY)
358 );
359 classify_non_authoritative_error(operation_class, context, source, replay_protocol_error)
360}
361
362pub(crate) fn classify_sent_nfs3_error(
363 operation_class: OperationClass,
364 context: RequestContext,
365 source: NfsError,
366) -> NfsError {
367 classify_non_authoritative_error(operation_class, context, source, false)
368}
369
370pub(crate) fn classify_sent_nfs40_error(
371 operation_class: OperationClass,
372 context: RequestContext,
373 source: NfsError,
374) -> NfsError {
375 classify_non_authoritative_error(operation_class, context, source, false)
376}
377
378fn classify_non_authoritative_error(
379 operation_class: OperationClass,
380 context: RequestContext,
381 source: NfsError,
382 replay_protocol_error: bool,
383) -> NfsError {
384 if source.request_transmission() == Some(RequestTransmission::NotSent) {
385 return NfsError::before_send_failure(operation_class, context, None, source);
386 }
387
388 let lacks_authoritative_result = replay_protocol_error
389 || source.request_transmission() == Some(RequestTransmission::Sent)
390 || matches!(
391 &source,
392 NfsError::Io(_) | NfsError::Rpc(_) | NfsError::Xdr(_)
393 );
394 if !lacks_authoritative_result {
395 return source;
396 }
397 let (outcome, recovery) = match operation_class {
398 OperationClass::ReadOnly => (OperationOutcome::SafeToRetry, RecoveryAction::Retry),
399 OperationClass::SessionControl => (OperationOutcome::Uncertain, RecoveryAction::Remount),
400 OperationClass::ReplaySensitive => (
401 OperationOutcome::Uncertain,
402 RecoveryAction::VerifyThenResume,
403 ),
404 };
405 NfsError::OperationOutcome(Box::new(OperationOutcomeError::new(
406 outcome,
407 operation_class,
408 recovery,
409 context,
410 source,
411 )))
412}
413
414pub type Result<T> = std::result::Result<T, NfsError>;
416
417impl From<NfsError> for std::io::Error {
419 fn from(e: NfsError) -> Self {
420 match e {
421 NfsError::Io(io) => io,
422 NfsError::Nfs3(code) => std::io::Error::other(code),
423 NfsError::Nfs4(code) => std::io::Error::other(code),
424 error @ NfsError::LockDenied { .. } => {
425 std::io::Error::new(std::io::ErrorKind::WouldBlock, error)
426 }
427 NfsError::Mount(code) => std::io::Error::other(code),
428 NfsError::Rpc(msg) => std::io::Error::other(msg),
429 NfsError::Xdr(msg) => std::io::Error::other(msg),
430 NfsError::Unsupported(msg) => std::io::Error::new(std::io::ErrorKind::Unsupported, msg),
431 NfsError::InvalidInput(msg) => {
432 std::io::Error::new(std::io::ErrorKind::InvalidInput, msg)
433 }
434 NfsError::ClosedResource(msg) => {
435 std::io::Error::new(std::io::ErrorKind::NotConnected, msg)
436 }
437 NfsError::ModeViolation(msg) => {
438 std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg)
439 }
440 NfsError::ClientClosed(msg) => {
441 std::io::Error::new(std::io::ErrorKind::NotConnected, msg)
442 }
443 NfsError::PositionUncertain(msg) | NfsError::LostOpenState(msg) => {
444 std::io::Error::other(msg)
445 }
446 NfsError::FileClose(errors) => std::io::Error::other(
447 errors
448 .first()
449 .map_or("file close completed with errors".to_string(), |failure| {
450 failure.error.to_string()
451 }),
452 ),
453 NfsError::RdattrError(code) => {
454 std::io::Error::other(format!("rdattr_error: nfsstat4 {}", code))
455 }
456 NfsError::OperationOutcome(error) => std::io::Error::other(error),
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use std::error::Error;
465
466 #[test]
467 fn nfs3_error_display() {
468 let err = NfsError::Nfs3(crate::nfs3::ErrorCode::NFS3ERR_PERM);
469 assert!(err.to_string().contains("NFS3 error"));
470 }
471
472 #[test]
473 fn nfs4_error_display() {
474 let err = NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_PERM);
475 assert!(err.to_string().contains("NFS4 error"));
476 assert!(err.to_string().contains("permission denied"));
477 }
478
479 #[test]
480 fn nfs4_error_kind_is_other() {
481 let err = NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_STALE);
482 assert_eq!(err.kind(), std::io::ErrorKind::Other);
483 }
484
485 #[test]
486 fn mount_error_display() {
487 let err = NfsError::Mount(crate::nfs3::MountErrorCode::MNT3ERR_PERM);
488 assert!(err.to_string().contains("Mount error"));
489 }
490
491 #[test]
492 fn rpc_error_display() {
493 let err = NfsError::Rpc("bad response".to_string());
494 assert_eq!(err.to_string(), "RPC error: bad response");
495 }
496
497 #[test]
498 fn xdr_error_display() {
499 let err = NfsError::Xdr("truncated".to_string());
500 assert_eq!(err.to_string(), "XDR error: truncated");
501 }
502
503 #[test]
504 fn unsupported_display() {
505 let err = NfsError::Unsupported("NFSv4 required".to_string());
506 assert_eq!(err.to_string(), "NFSv4 required");
507 }
508
509 #[test]
510 fn invalid_input_display() {
511 let err = NfsError::InvalidInput("bad URL".to_string());
512 assert_eq!(err.to_string(), "bad URL");
513 }
514
515 #[test]
516 fn io_error_transparent_display() {
517 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "connection lost");
518 let err = NfsError::Io(io_err);
519 assert_eq!(err.to_string(), "connection lost");
520 }
521
522 #[test]
523 fn from_io_error() {
524 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
525 let nfs_err: NfsError = io_err.into();
526 assert!(matches!(nfs_err, NfsError::Io(_)));
527 }
528
529 #[test]
530 fn kind_io_preserves_inner() {
531 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
532 let err = NfsError::Io(io_err);
533 assert_eq!(err.kind(), std::io::ErrorKind::ConnectionRefused);
534 }
535
536 #[test]
537 fn kind_nfs3_is_other() {
538 let err = NfsError::Nfs3(crate::nfs3::ErrorCode::NFS3ERR_NOENT);
539 assert_eq!(err.kind(), std::io::ErrorKind::Other);
540 }
541
542 #[test]
543 fn kind_unsupported() {
544 let err = NfsError::Unsupported("test".to_string());
545 assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
546 }
547
548 #[test]
549 fn kind_invalid_input() {
550 let err = NfsError::InvalidInput("test".to_string());
551 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
552 }
553
554 #[test]
555 fn is_exist_nfs3() {
556 let err = NfsError::Nfs3(crate::nfs3::ErrorCode::NFS3ERR_EXIST);
557 assert!(err.is_exist());
558 }
559
560 #[test]
561 fn is_exist_nfs4() {
562 let err = NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_EXIST);
563 assert!(err.is_exist());
564 }
565
566 #[test]
567 fn is_exist_false_for_other() {
568 let err = NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_NOENT);
569 assert!(!err.is_exist());
570 }
571
572 #[test]
573 fn is_not_found_nfs3() {
574 let err = NfsError::Nfs3(crate::nfs3::ErrorCode::NFS3ERR_NOENT);
575 assert!(err.is_not_found());
576 }
577
578 #[test]
579 fn is_not_found_nfs4() {
580 let err = NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_NOENT);
581 assert!(err.is_not_found());
582 }
583
584 #[test]
585 fn is_not_found_false_for_exist() {
586 let err = NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_EXIST);
587 assert!(!err.is_not_found());
588 }
589
590 #[test]
591 fn into_io_error_roundtrip() {
592 let nfs_err = NfsError::Rpc("test rpc error".to_string());
593 let io_err: std::io::Error = nfs_err.into();
594 assert_eq!(io_err.kind(), std::io::ErrorKind::Other);
595 assert!(io_err.to_string().contains("test rpc error"));
596 }
597
598 #[test]
599 fn into_io_error_preserves_io() {
600 let original = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "broken");
601 let nfs_err = NfsError::Io(original);
602 let io_err: std::io::Error = nfs_err.into();
603 assert_eq!(io_err.kind(), std::io::ErrorKind::BrokenPipe);
604 }
605
606 fn context() -> RequestContext {
607 RequestContext {
608 operation: "write".to_string(),
609 protocol: NFSVersion::NFSv4p1,
610 request_id: Some(RequestId::nfs41([7; 16], 3, 9)),
611 }
612 }
613
614 #[test]
615 fn sent_read_only_transport_failure_is_safe_to_retry() {
616 let error = classify_sent_nfs41_error(
617 OperationClass::ReadOnly,
618 context(),
619 NfsError::Io(std::io::Error::new(
620 std::io::ErrorKind::TimedOut,
621 "reply timeout",
622 )),
623 );
624 let outcome = error
625 .operation_outcome()
626 .expect("outcome must be structured");
627 assert_eq!(outcome.outcome, OperationOutcome::SafeToRetry);
628 assert_eq!(outcome.recovery, RecoveryAction::Retry);
629 assert_eq!(outcome.context(), &context());
630 assert!(outcome.source().is_some());
631 }
632
633 #[test]
634 fn sent_modifying_transport_failure_is_uncertain() {
635 let error = classify_sent_nfs41_error(
636 OperationClass::ReplaySensitive,
637 context(),
638 NfsError::Io(std::io::Error::new(
639 std::io::ErrorKind::ConnectionReset,
640 "lost after send",
641 )),
642 );
643 let outcome = error
644 .operation_outcome()
645 .expect("outcome must be structured");
646 assert_eq!(outcome.outcome, OperationOutcome::Uncertain);
647 assert_eq!(outcome.recovery, RecoveryAction::VerifyThenResume);
648 assert_eq!(outcome.operation_class, OperationClass::ReplaySensitive);
649 }
650
651 #[test]
652 fn replay_protocol_errors_have_operation_aware_outcomes() {
653 for code in [
654 crate::nfs4::Nfs4ErrorCode::NFS4ERR_RETRY_UNCACHED_REP,
655 crate::nfs4::Nfs4ErrorCode::NFS4ERR_SEQ_FALSE_RETRY,
656 ] {
657 let read = classify_sent_nfs41_error(
658 OperationClass::ReadOnly,
659 context(),
660 NfsError::Nfs4(code),
661 );
662 assert_eq!(
663 read.operation_outcome().map(|error| error.outcome),
664 Some(OperationOutcome::SafeToRetry)
665 );
666
667 let write = classify_sent_nfs41_error(
668 OperationClass::ReplaySensitive,
669 context(),
670 NfsError::Nfs4(code),
671 );
672 assert_eq!(
673 write.operation_outcome().map(|error| error.outcome),
674 Some(OperationOutcome::Uncertain)
675 );
676 }
677 }
678
679 #[test]
680 fn authoritative_protocol_failure_remains_definite_and_unwrapped() {
681 let error = classify_sent_nfs41_error(
682 OperationClass::ReplaySensitive,
683 context(),
684 NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_ACCESS),
685 );
686 assert!(error.operation_outcome().is_none());
687 assert!(matches!(
688 error,
689 NfsError::Nfs4(crate::nfs4::Nfs4ErrorCode::NFS4ERR_ACCESS)
690 ));
691 }
692
693 #[test]
694 fn outcome_error_preserves_source_without_payload_context() {
695 let error = classify_sent_nfs41_error(
696 OperationClass::ReplaySensitive,
697 context(),
698 NfsError::Rpc("truncated authoritative reply".to_string()),
699 );
700 let outcome = error
701 .operation_outcome()
702 .expect("outcome must be structured");
703 assert!(
704 matches!(&*outcome.source, NfsError::Rpc(message) if message.contains("truncated"))
705 );
706 let debug = format!("{outcome:?}");
707 assert!(!debug.contains("file handle"));
708 assert!(!debug.contains("payload"));
709 }
710
711 #[test]
712 fn modifying_failure_before_send_is_definite_and_preserves_completed_bytes() {
713 let error = NfsError::before_send_failure(
714 OperationClass::ReplaySensitive,
715 context(),
716 Some(4096),
717 NfsError::Io(std::io::Error::new(
718 std::io::ErrorKind::ConnectionRefused,
719 "request was not sent",
720 )),
721 );
722 let outcome = error
723 .operation_outcome()
724 .expect("outcome must be structured");
725
726 assert_eq!(outcome.outcome, OperationOutcome::DefiniteFailure);
727 assert_eq!(outcome.transmission, RequestTransmission::NotSent);
728 assert_eq!(outcome.completed_bytes, Some(4096));
729 assert_eq!(outcome.recovery, RecoveryAction::Retry);
730 }
731
732 #[test]
733 fn sent_modifying_failure_records_sent_transmission() {
734 let error = classify_sent_nfs41_error(
735 OperationClass::ReplaySensitive,
736 context(),
737 NfsError::Io(std::io::Error::new(
738 std::io::ErrorKind::ConnectionReset,
739 "lost after send",
740 )),
741 );
742 let outcome = error
743 .operation_outcome()
744 .expect("outcome must be structured");
745
746 assert_eq!(outcome.transmission, RequestTransmission::Sent);
747 assert_eq!(outcome.completed_bytes, None);
748 }
749
750 #[test]
751 fn uncertain_chunked_operation_preserves_only_confirmed_bytes() {
752 let error = OperationOutcomeError::new(
753 OperationOutcome::Uncertain,
754 OperationClass::ReplaySensitive,
755 RecoveryAction::VerifyThenResume,
756 context(),
757 NfsError::Rpc("reply lost for current chunk".to_string()),
758 )
759 .with_completed_bytes(8192);
760
761 assert_eq!(error.completed_bytes, Some(8192));
762 assert_eq!(error.transmission, RequestTransmission::Sent);
763 }
764
765 #[test]
766 fn sent_nfs3_mutation_without_reply_is_uncertain() {
767 let mut nfs3_context = context();
768 nfs3_context.protocol = NFSVersion::NFSv3;
769 nfs3_context.request_id = None;
770 let error = classify_sent_nfs3_error(
771 OperationClass::ReplaySensitive,
772 nfs3_context,
773 NfsError::Io(std::io::Error::new(
774 std::io::ErrorKind::TimedOut,
775 "NFSv3 reply timeout",
776 )),
777 );
778 let outcome = error
779 .operation_outcome()
780 .expect("sent NFSv3 mutation must have a structured outcome");
781
782 assert_eq!(outcome.outcome, OperationOutcome::Uncertain);
783 assert_eq!(outcome.transmission, RequestTransmission::Sent);
784 assert_eq!(outcome.recovery, RecoveryAction::VerifyThenResume);
785 }
786
787 #[test]
788 fn nfs3_transport_evidence_preserves_before_send_failure() {
789 let mut nfs3_context = context();
790 nfs3_context.protocol = NFSVersion::NFSv3;
791 nfs3_context.request_id = None;
792 let error = classify_sent_nfs3_error(
793 OperationClass::ReplaySensitive,
794 nfs3_context,
795 NfsError::transport(
796 RequestTransmission::NotSent,
797 NfsError::Rpc("connection was not ready".to_string()),
798 ),
799 );
800 let outcome = error
801 .operation_outcome()
802 .expect("transport evidence must be preserved");
803
804 assert_eq!(outcome.outcome, OperationOutcome::DefiniteFailure);
805 assert_eq!(outcome.transmission, RequestTransmission::NotSent);
806 assert_eq!(outcome.recovery, RecoveryAction::Retry);
807 }
808}