SMB Server Phase 2: VFS backend build fix + integration test
- Add VfsFile: Send supertrait for Mutex compatibility - Fix SmbServerCommand: struct → Subcommand enum with Start variant - Fix tracing_subscriber::init() → try_init() to avoid panic when logger already initialized - Fix CLI subcommand name: smb-server → smb-start (flatten naming) - Add #[command(name = "smb-start")] for CLI disambiguation - Fix unused variable warnings (smb_fs.rs, smb_server_backend.rs) - Remove unused VfsFile imports (webdav.rs, scp_handler.rs) - Integration test: Docker smbclient verified (list, upload, read)
This commit is contained in:
389
vendor/smb2/src/error.rs
vendored
Normal file
389
vendor/smb2/src/error.rs
vendored
Normal file
@@ -0,0 +1,389 @@
|
||||
//! Error types for the SMB2 library.
|
||||
|
||||
use crate::types::status::NtStatus;
|
||||
use crate::types::Command;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Top-level error type for SMB2 operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
/// The data is malformed or does not match the expected format.
|
||||
#[error("Invalid data: {message}")]
|
||||
InvalidData {
|
||||
/// Description of what went wrong.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The server returned a non-success NTSTATUS.
|
||||
#[error("Protocol error: {status} during {command:?}")]
|
||||
Protocol {
|
||||
/// The NTSTATUS code from the response header.
|
||||
status: NtStatus,
|
||||
/// The command that triggered the error.
|
||||
command: Command,
|
||||
},
|
||||
|
||||
/// Authentication failed.
|
||||
#[error("Authentication failed: {message}")]
|
||||
Auth {
|
||||
/// Description of what went wrong.
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// An I/O or transport error occurred.
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
/// The operation timed out.
|
||||
#[error("Operation timed out")]
|
||||
Timeout,
|
||||
|
||||
/// The connection was lost.
|
||||
#[error("Disconnected from server")]
|
||||
Disconnected,
|
||||
|
||||
/// The path requires DFS referral resolution.
|
||||
///
|
||||
/// The server returned `STATUS_PATH_NOT_COVERED`, meaning this path
|
||||
/// lives on a different server via DFS. The caller can query for a
|
||||
/// referral or display a helpful message.
|
||||
#[error("DFS referral required for path: {path}")]
|
||||
DfsReferralRequired {
|
||||
/// The path that needs DFS resolution.
|
||||
path: String,
|
||||
},
|
||||
|
||||
/// The operation was cancelled by the caller (via progress callback).
|
||||
#[error("Operation cancelled")]
|
||||
Cancelled,
|
||||
|
||||
/// The session expired and reauthentication failed.
|
||||
///
|
||||
/// The pipeline normally handles `STATUS_NETWORK_SESSION_EXPIRED`
|
||||
/// transparently by reauthenticating. This error surfaces only
|
||||
/// when reauthentication itself fails.
|
||||
#[error("Session expired and reauthentication failed")]
|
||||
SessionExpired,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Create an `InvalidData` error with the given message.
|
||||
pub fn invalid_data(msg: impl Into<String>) -> Self {
|
||||
Error::InvalidData {
|
||||
message: msg.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this error is potentially transient and
|
||||
/// the operation could succeed on retry.
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Timeout
|
||||
| Error::Disconnected
|
||||
| Error::Protocol {
|
||||
status: NtStatus::INSUFFICIENT_RESOURCES,
|
||||
..
|
||||
}
|
||||
| Error::Protocol {
|
||||
status: NtStatus::INSUFF_SERVER_RESOURCES,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the NTSTATUS code if this is a protocol error.
|
||||
pub fn status(&self) -> Option<NtStatus> {
|
||||
match self {
|
||||
Error::Protocol { status, .. } => Some(*status),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level error classification.
|
||||
///
|
||||
/// Maps protocol-level NTSTATUS codes and other errors into categories
|
||||
/// that consumers can match on without understanding SMB internals.
|
||||
///
|
||||
/// ```no_run
|
||||
/// # async fn example(client: &mut smb2::SmbClient, share: &mut smb2::Tree) -> Result<(), smb2::Error> {
|
||||
/// use smb2::ErrorKind;
|
||||
///
|
||||
/// match client.read_file(share, "photo.jpg").await {
|
||||
/// Ok(data) => println!("read {} bytes", data.len()),
|
||||
/// Err(e) => match e.kind() {
|
||||
/// ErrorKind::NotFound => println!("file doesn't exist"),
|
||||
/// ErrorKind::AlreadyExists => println!("name is already taken"),
|
||||
/// ErrorKind::AccessDenied => println!("no permission"),
|
||||
/// ErrorKind::SigningRequired => println!("server requires signing, use credentials"),
|
||||
/// ErrorKind::AuthRequired => println!("server requires authentication"),
|
||||
/// ErrorKind::SharingViolation => println!("file is in use by another client"),
|
||||
/// ErrorKind::IsADirectory => println!("path is a directory, not a file"),
|
||||
/// ErrorKind::NotADirectory => println!("path is a file, not a directory"),
|
||||
/// ErrorKind::DiskFull => println!("volume is full"),
|
||||
/// ErrorKind::ConnectionLost => { client.reconnect().await?; }
|
||||
/// _ => return Err(e),
|
||||
/// }
|
||||
/// }
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// # Stability
|
||||
///
|
||||
/// `ErrorKind` is `#[non_exhaustive]`: future versions may add variants for
|
||||
/// status codes that currently fall through to [`ErrorKind::Other`]. Match
|
||||
/// statements should always include a `_` arm. Adding a variant is treated
|
||||
/// as a non-breaking change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ErrorKind {
|
||||
/// The server requires authentication (guest/anonymous not allowed).
|
||||
AuthRequired,
|
||||
/// The server requires message signing (guest sessions are unsigned).
|
||||
SigningRequired,
|
||||
/// Permission denied (valid credentials, but no access to this resource).
|
||||
AccessDenied,
|
||||
/// The file, directory, or share was not found.
|
||||
NotFound,
|
||||
/// A file or directory with the given name already exists.
|
||||
///
|
||||
/// Returned by `Create` (and operations that wrap it, like `create_directory`)
|
||||
/// when the target name is taken. Useful for callers that want to merge into
|
||||
/// an existing directory or surface a friendly "name already taken" message.
|
||||
AlreadyExists,
|
||||
/// The file is in use by another client.
|
||||
SharingViolation,
|
||||
/// The target path is a directory, but the operation expected a file.
|
||||
///
|
||||
/// Typically seen when calling `delete_file` against a directory entry —
|
||||
/// the caller can fall back to `delete_directory` after detecting this.
|
||||
IsADirectory,
|
||||
/// The target path is a file, but the operation expected a directory.
|
||||
///
|
||||
/// Typically seen when calling `list_directory` against a file entry.
|
||||
NotADirectory,
|
||||
/// The volume is full (write failed).
|
||||
DiskFull,
|
||||
/// The network connection was lost.
|
||||
ConnectionLost,
|
||||
/// The operation timed out.
|
||||
TimedOut,
|
||||
/// The operation was cancelled by the caller.
|
||||
Cancelled,
|
||||
/// The session expired (call `reconnect()`).
|
||||
SessionExpired,
|
||||
/// The path requires DFS referral resolution.
|
||||
DfsReferral,
|
||||
/// Invalid data or malformed response.
|
||||
InvalidData,
|
||||
/// An I/O error (transport or callback). Not necessarily a connection loss.
|
||||
///
|
||||
/// Distinct from `ConnectionLost`: the connection may still be usable.
|
||||
/// For example, a callback error in `write_file_streamed` produces `Io`,
|
||||
/// but the connection is still in a clean state.
|
||||
Io,
|
||||
/// A protocol error not covered by other variants.
|
||||
///
|
||||
/// Use [`Error::status()`] to get the raw NTSTATUS code. Some defined
|
||||
/// `NtStatus` codes deliberately fall through here today
|
||||
/// (`OBJECT_NAME_INVALID`, `DELETE_PENDING`, `INSUFFICIENT_RESOURCES`,
|
||||
/// `INSUFF_SERVER_RESOURCES`, and similar) — they don't yet have a
|
||||
/// dedicated `ErrorKind` because no consumer needs to branch on them.
|
||||
/// Promoting one to its own variant is non-breaking.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Classify this error into a high-level category.
|
||||
///
|
||||
/// Consumers can match on [`ErrorKind`] without understanding raw
|
||||
/// NTSTATUS codes. For the underlying status code, use [`status()`](Self::status).
|
||||
pub fn kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
Error::InvalidData { .. } => ErrorKind::InvalidData,
|
||||
Error::Auth { .. } => ErrorKind::AuthRequired,
|
||||
Error::Io(_) => ErrorKind::Io,
|
||||
Error::Disconnected => ErrorKind::ConnectionLost,
|
||||
Error::Timeout => ErrorKind::TimedOut,
|
||||
Error::Cancelled => ErrorKind::Cancelled,
|
||||
Error::SessionExpired => ErrorKind::SessionExpired,
|
||||
Error::DfsReferralRequired { .. } => ErrorKind::DfsReferral,
|
||||
Error::Protocol { status, .. } => classify_status(*status),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an NTSTATUS to an ErrorKind.
|
||||
fn classify_status(status: NtStatus) -> ErrorKind {
|
||||
match status {
|
||||
// Auth / signing
|
||||
NtStatus::LOGON_FAILURE | NtStatus::ACCOUNT_DISABLED => ErrorKind::AuthRequired,
|
||||
NtStatus::ACCESS_DENIED => {
|
||||
// Could be signing-required or genuinely access-denied.
|
||||
// Callers with NegotiatedParams context can distinguish further.
|
||||
// Default to AccessDenied; SmbClient methods can upgrade to
|
||||
// SigningRequired when signing_required is true.
|
||||
ErrorKind::AccessDenied
|
||||
}
|
||||
|
||||
// Not found
|
||||
NtStatus::NO_SUCH_FILE
|
||||
| NtStatus::OBJECT_NAME_NOT_FOUND
|
||||
| NtStatus::OBJECT_PATH_NOT_FOUND
|
||||
| NtStatus::BAD_NETWORK_NAME => ErrorKind::NotFound,
|
||||
|
||||
// Already exists
|
||||
NtStatus::OBJECT_NAME_COLLISION => ErrorKind::AlreadyExists,
|
||||
|
||||
// Wrong file type
|
||||
NtStatus::FILE_IS_A_DIRECTORY => ErrorKind::IsADirectory,
|
||||
NtStatus::NOT_A_DIRECTORY => ErrorKind::NotADirectory,
|
||||
|
||||
// Sharing / locking
|
||||
NtStatus::SHARING_VIOLATION | NtStatus::FILE_LOCK_CONFLICT => ErrorKind::SharingViolation,
|
||||
|
||||
// Disk full
|
||||
NtStatus::DISK_FULL => ErrorKind::DiskFull,
|
||||
|
||||
// Session expired
|
||||
NtStatus::NETWORK_SESSION_EXPIRED => ErrorKind::SessionExpired,
|
||||
|
||||
// Connection
|
||||
NtStatus::NETWORK_NAME_DELETED | NtStatus::USER_SESSION_DELETED => {
|
||||
ErrorKind::ConnectionLost
|
||||
}
|
||||
|
||||
// DFS
|
||||
NtStatus::PATH_NOT_COVERED => ErrorKind::DfsReferral,
|
||||
|
||||
// Everything else
|
||||
_ => ErrorKind::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Result` type alias using the crate's [`Error`](enum@Error) type.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Documents the full contract between `NtStatus` codes and `ErrorKind`.
|
||||
///
|
||||
/// Every code listed here is asserted to map to its expected variant. When
|
||||
/// adding a new `NtStatus` to `types/status.rs`, also add a row here — either
|
||||
/// pointing at a dedicated `ErrorKind`, or `ErrorKind::Other` if there is
|
||||
/// genuinely no consumer-meaningful classification yet. The companion test
|
||||
/// `classify_status_no_silent_other` then guarantees the table stays in sync
|
||||
/// with what `classify_status` actually does.
|
||||
const STATUS_CLASSIFICATION_CONTRACT: &[(NtStatus, ErrorKind)] = &[
|
||||
// Auth / signing
|
||||
(NtStatus::LOGON_FAILURE, ErrorKind::AuthRequired),
|
||||
(NtStatus::ACCOUNT_DISABLED, ErrorKind::AuthRequired),
|
||||
(NtStatus::ACCESS_DENIED, ErrorKind::AccessDenied),
|
||||
// Not found
|
||||
(NtStatus::NO_SUCH_FILE, ErrorKind::NotFound),
|
||||
(NtStatus::OBJECT_NAME_NOT_FOUND, ErrorKind::NotFound),
|
||||
(NtStatus::OBJECT_PATH_NOT_FOUND, ErrorKind::NotFound),
|
||||
(NtStatus::BAD_NETWORK_NAME, ErrorKind::NotFound),
|
||||
// Already exists
|
||||
(NtStatus::OBJECT_NAME_COLLISION, ErrorKind::AlreadyExists),
|
||||
// Wrong file type
|
||||
(NtStatus::FILE_IS_A_DIRECTORY, ErrorKind::IsADirectory),
|
||||
(NtStatus::NOT_A_DIRECTORY, ErrorKind::NotADirectory),
|
||||
// Sharing / locking
|
||||
(NtStatus::SHARING_VIOLATION, ErrorKind::SharingViolation),
|
||||
(NtStatus::FILE_LOCK_CONFLICT, ErrorKind::SharingViolation),
|
||||
// Disk
|
||||
(NtStatus::DISK_FULL, ErrorKind::DiskFull),
|
||||
// Connection / session
|
||||
(NtStatus::NETWORK_NAME_DELETED, ErrorKind::ConnectionLost),
|
||||
(NtStatus::USER_SESSION_DELETED, ErrorKind::ConnectionLost),
|
||||
(NtStatus::NETWORK_SESSION_EXPIRED, ErrorKind::SessionExpired),
|
||||
// DFS
|
||||
(NtStatus::PATH_NOT_COVERED, ErrorKind::DfsReferral),
|
||||
// Documented `Other` (no current consumer demand for a typed variant)
|
||||
(NtStatus::NOT_IMPLEMENTED, ErrorKind::Other),
|
||||
(NtStatus::INVALID_PARAMETER, ErrorKind::Other),
|
||||
(NtStatus::DELETE_PENDING, ErrorKind::Other),
|
||||
(NtStatus::INSUFFICIENT_RESOURCES, ErrorKind::Other),
|
||||
(NtStatus::INSUFF_SERVER_RESOURCES, ErrorKind::Other),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn classify_status_contract() {
|
||||
for (status, expected) in STATUS_CLASSIFICATION_CONTRACT {
|
||||
let err = Error::Protocol {
|
||||
status: *status,
|
||||
command: Command::Create,
|
||||
};
|
||||
assert_eq!(
|
||||
err.kind(),
|
||||
*expected,
|
||||
"{status} should classify as {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_maps_non_protocol_errors() {
|
||||
assert_eq!(Error::Timeout.kind(), ErrorKind::TimedOut);
|
||||
assert_eq!(Error::Disconnected.kind(), ErrorKind::ConnectionLost);
|
||||
assert_eq!(Error::Cancelled.kind(), ErrorKind::Cancelled);
|
||||
assert_eq!(Error::SessionExpired.kind(), ErrorKind::SessionExpired);
|
||||
assert_eq!(Error::invalid_data("test").kind(), ErrorKind::InvalidData);
|
||||
assert_eq!(
|
||||
Error::DfsReferralRequired {
|
||||
path: "test".into()
|
||||
}
|
||||
.kind(),
|
||||
ErrorKind::DfsReferral
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Auth {
|
||||
message: "test".into()
|
||||
}
|
||||
.kind(),
|
||||
ErrorKind::AuthRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_maps_io_error_to_io_not_connection_lost() {
|
||||
// Error::Io from callback errors (like write_file_streamed cancellation)
|
||||
// should NOT be ConnectionLost — the connection may still be usable.
|
||||
let err = Error::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"cancelled",
|
||||
));
|
||||
assert_eq!(err.kind(), ErrorKind::Io);
|
||||
assert_ne!(err.kind(), ErrorKind::ConnectionLost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_disconnected_is_connection_lost() {
|
||||
// Error::Disconnected (transport EOF) IS a connection loss.
|
||||
assert_eq!(Error::Disconnected.kind(), ErrorKind::ConnectionLost);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_maps_dfs_referral_required_to_dfs_referral() {
|
||||
// The explicit DFS referral error variant should also map to DfsReferral.
|
||||
let err = Error::DfsReferralRequired {
|
||||
path: r"\\server\share\path".into(),
|
||||
};
|
||||
assert_eq!(err.kind(), ErrorKind::DfsReferral);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dfs_referral_is_not_retryable() {
|
||||
// DFS referrals need special handling, not generic retry.
|
||||
let err = Error::Protocol {
|
||||
status: NtStatus::PATH_NOT_COVERED,
|
||||
command: Command::Create,
|
||||
};
|
||||
assert!(!err.is_retryable());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user