Skip to main content

nfs_rs/
error.rs

1// Copyright 2025 NetApp Inc. All Rights Reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17use crate::mount::NFSVersion;
18use thiserror::Error;
19
20/// Retry/recovery safety of an NFS operation after an authoritative result is unavailable.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum OperationOutcome {
23    /// The request is known not to have taken effect.
24    DefiniteFailure,
25    /// Repeating the operation is safe because it is read-only or idempotent.
26    SafeToRetry,
27    /// The request may have taken effect; blind retry can duplicate a mutation.
28    Uncertain,
29}
30
31/// Protocol-level side-effect class used for migration recovery decisions.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum OperationClass {
34    ReadOnly,
35    SessionControl,
36    ReplaySensitive,
37}
38
39/// Recovery action recommended to callers without requiring string parsing.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum RecoveryAction {
42    Retry,
43    Reopen,
44    Remount,
45    VerifyThenResume,
46    DoNotRetry,
47}
48
49/// Whether the request crossed the transport send boundary.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum RequestTransmission {
52    /// The core proved that no request bytes were sent.
53    NotSent,
54    /// The request crossed the send boundary and may have reached the server.
55    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/// Opaque, bounded request identity. It deliberately excludes file handles and payload bytes.
77#[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/// Protocol-neutral context for classifying a request with an uncertain outcome.
113#[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/// Structured disposition for an operation whose normal result was not returned.
121#[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    /// Records bytes completed by authoritative preceding chunks. The current
158    /// uncertain chunk must never be included in this count.
159    pub fn with_completed_bytes(mut self, completed_bytes: u64) -> Self {
160        self.completed_bytes = Some(completed_bytes);
161        self
162    }
163}
164
165/// Structured error type for NFS operations.
166///
167/// Replaces the previous `std::io::Error` + `ErrorKind::Other` pattern,
168/// preserving NFS protocol error codes for programmatic matching.
169#[derive(Error, Debug)]
170pub enum NfsError {
171    /// Underlying I/O or network error.
172    #[error("{0}")]
173    Io(#[from] std::io::Error),
174
175    /// NFS3 protocol error (nfsstat3).
176    #[error("NFS3 error: {0}")]
177    Nfs3(crate::nfs3::ErrorCode),
178
179    /// NFS4 protocol error (nfsstat4).
180    #[error("NFS4 error: {0}")]
181    Nfs4(crate::nfs4::Nfs4ErrorCode),
182
183    /// NFSv4 byte-range lock conflict, including the server-reported range.
184    #[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    /// MOUNT protocol error (mount_mountstat3).
193    #[error("Mount error: {0}")]
194    Mount(crate::nfs3::MountErrorCode),
195
196    /// RPC-level error (rejected, mismatch, truncated response).
197    #[error("RPC error: {0}")]
198    Rpc(String),
199
200    /// XDR encoding/decoding error.
201    #[error("XDR error: {0}")]
202    Xdr(String),
203
204    /// Operation not supported by this NFS version.
205    #[error("{0}")]
206    Unsupported(String),
207
208    /// Invalid input (bad URL, bad parameters).
209    #[error("{0}")]
210    InvalidInput(String),
211
212    /// A local operation targeted a resource that is already closed.
213    #[error("{0}")]
214    ClosedResource(String),
215
216    /// The open mode does not permit the requested operation.
217    #[error("{0}")]
218    ModeViolation(String),
219
220    /// The connected client is already closing or closed.
221    #[error("{0}")]
222    ClientClosed(String),
223
224    /// A prior uncertain mutation made the next relative position unknowable.
225    #[error("{0}")]
226    PositionUncertain(String),
227
228    /// Recovery could not prove that an open resource retained identity and state.
229    #[error("{0}")]
230    LostOpenState(String),
231
232    /// Ordered failures observed while flushing and closing one open file.
233    #[error("file close completed with errors")]
234    FileClose(Vec<FileCloseFailure>),
235
236    /// Server returned rdattr_error for an entry (READDIRPLUS per-entry error).
237    #[error("rdattr_error: server returned nfsstat4 {0} for entry attributes")]
238    RdattrError(u32),
239
240    /// An operation failed without an authoritative result and carries retry guidance.
241    #[error(transparent)]
242    OperationOutcome(#[from] Box<OperationOutcomeError>),
243}
244
245/// One ordered phase failure from a terminal file close.
246#[derive(Debug)]
247pub struct FileCloseFailure {
248    /// Stable operation name (`write`, `commit` or `close`).
249    pub operation: &'static str,
250    /// The original structured failure.
251    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    /// Returns transport send-boundary evidence when it is available.
267    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    /// Wraps a failure for which the caller proved that the current request did
279    /// not cross the transport send boundary.
280    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    /// Returns structured retry/verification guidance when the result is non-authoritative.
299    pub fn operation_outcome(&self) -> Option<&OperationOutcomeError> {
300        match self {
301            Self::OperationOutcome(error) => Some(error),
302            _ => None,
303        }
304    }
305
306    /// 目标已存在(NFS3ERR_EXIST / NFS4ERR_EXIST,errno 17)
307    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    /// 目标不存在(NFS3ERR_NOENT / NFS4ERR_NOENT,errno 2)
316    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    /// Returns the corresponding `std::io::ErrorKind` for backward compatibility
325    /// with code that matches on error kinds.
326    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
414/// Convenience alias used throughout the crate.
415pub type Result<T> = std::result::Result<T, NfsError>;
416
417/// Convert NfsError back to std::io::Error for backward compatibility.
418impl 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}