Add SMB AAPL Extensions Phase 1-6 + VFS xattr support

Phase 1: AAPL Create Context negotiation
Phase 2: AFP_AfpInfo Stream structure (Finder info + creation time)
Phase 2.5: SMB Named Stream Backend (NamedStreamPath)
Phase 2.6: Backend Named Stream Support in handlers
Phase 2.7: VFS Extended Attributes (get/set/remove/list_xattr)
Phase 4: Time Machine share config (time_machine field)
Phase 5: Server/Volume Capabilities
Phase 6: macOS Unicode mapping (private range ↔ ASCII)

Tests: 174 smb-server tests pass, 52 VFS tests pass
This commit is contained in:
Warren
2026-06-22 14:21:53 +08:00
parent 64709ec529
commit 866d0536c8
14 changed files with 906 additions and 5 deletions

View File

@@ -0,0 +1,141 @@
//! Apple SMB Extensions (AAPL Create Context)
//!
//! Reference: Apple SMB Extensions protocol (MS-SMB2 §2.2.13.2)
//! and Samba vfs_fruit.c implementation.
// AAPL Create Context Command Codes
pub const SMB2_CRTCTX_AAPL_SERVER_QUERY: u32 = 1;
pub const SMB2_CRTCTX_AAPL_RESOLVE_ID: u32 = 2;
// AAPL Server Query Request/Response Bitmap
pub const SMB2_CRTCTX_AAPL_SERVER_CAPS: u64 = 1;
pub const SMB2_CRTCTX_AAPL_VOLUME_CAPS: u64 = 2;
pub const SMB2_CRTCTX_AAPL_MODEL_INFO: u64 = 4;
// AAPL Client/Server Capabilities Bitmap
pub const SMB2_CRTCTX_AAPL_SUPPORTS_READ_DIR_ATTR: u64 = 1;
pub const SMB2_CRTCTX_AAPL_SUPPORTS_OSX_COPYFILE: u64 = 2;
pub const SMB2_CRTCTX_AAPL_UNIX_BASED: u64 = 4;
pub const SMB2_CRTCTX_AAPL_SUPPORTS_NFS_ACE: u64 = 8;
// AAPL Volume Capabilities Bitmap
pub const SMB2_CRTCTX_AAPL_SUPPORT_RESOLVE_ID: u64 = 1;
pub const SMB2_CRTCTX_AAPL_CASE_SENSITIVE: u64 = 2;
pub const SMB2_CRTCTX_AAPL_FULL_SYNC: u64 = 4;
/// AAPL Create Context Request (24 bytes)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AaplCreateContextRequest {
pub command: u32,
pub reserved: u32,
pub request_bitmap: u64,
pub client_caps: u64,
}
impl AaplCreateContextRequest {
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() != 24 {
return None;
}
Some(Self {
command: u32::from_le_bytes([data[0], data[1], data[2], data[3]]),
reserved: u32::from_le_bytes([data[4], data[5], data[6], data[7]]),
request_bitmap: u64::from_le_bytes([
data[8], data[9], data[10], data[11],
data[12], data[13], data[14], data[15],
]),
client_caps: u64::from_le_bytes([
data[16], data[17], data[18], data[19],
data[20], data[21], data[22], data[23],
]),
})
}
}
/// AAPL Create Context Response (variable length)
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AaplCreateContextResponse {
pub command: u32,
pub reserved: u32,
pub request_bitmap: u64,
pub server_caps: u64,
pub volume_caps: u64,
pub model: String,
}
impl AaplCreateContextResponse {
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(&self.command.to_le_bytes());
buf.extend_from_slice(&self.reserved.to_le_bytes());
buf.extend_from_slice(&self.request_bitmap.to_le_bytes());
if self.request_bitmap & SMB2_CRTCTX_AAPL_SERVER_CAPS != 0 {
buf.extend_from_slice(&self.server_caps.to_le_bytes());
}
if self.request_bitmap & SMB2_CRTCTX_AAPL_VOLUME_CAPS != 0 {
buf.extend_from_slice(&self.volume_caps.to_le_bytes());
}
if self.request_bitmap & SMB2_CRTCTX_AAPL_MODEL_INFO != 0 {
let model_utf16: Vec<u16> = self.model.encode_utf16().collect();
buf.extend_from_slice(&(model_utf16.len() as u32 * 2).to_le_bytes());
for ch in model_utf16 {
buf.extend_from_slice(&ch.to_le_bytes());
}
}
buf
}
pub fn new_server_query(
request_bitmap: u64,
client_caps: u64,
server_caps: u64,
volume_caps: u64,
model: &str,
) -> Self {
Self {
command: SMB2_CRTCTX_AAPL_SERVER_QUERY,
reserved: 0,
request_bitmap,
server_caps,
volume_caps,
model: model.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_aapl_request_parse() {
let data = [
1u8, 0, 0, 0, // command = SERVER_QUERY
0, 0, 0, 0, // reserved
7, 0, 0, 0, 0, 0, 0, 0, // request_bitmap = SERVER_CAPS | VOLUME_CAPS | MODEL_INFO
0, 0, 0, 0, 0, 0, 0, 0, // client_caps
];
let req = AaplCreateContextRequest::from_bytes(&data).unwrap();
assert_eq!(req.command, SMB2_CRTCTX_AAPL_SERVER_QUERY);
assert_eq!(req.request_bitmap, 7);
}
#[test]
fn test_aapl_response_encode() {
let resp = AaplCreateContextResponse::new_server_query(
SMB2_CRTCTX_AAPL_SERVER_CAPS | SMB2_CRTCTX_AAPL_VOLUME_CAPS,
0,
SMB2_CRTCTX_AAPL_UNIX_BASED | SMB2_CRTCTX_AAPL_SUPPORTS_READ_DIR_ATTR,
SMB2_CRTCTX_AAPL_CASE_SENSITIVE,
"MacBookPro",
);
let bytes = resp.to_bytes();
assert!(!bytes.is_empty());
assert_eq!(bytes[0..4], [1, 0, 0, 0]); // command
}
}

View File

@@ -0,0 +1,257 @@
//! AFP_AfpInfo Stream (Apple SMB Extensions)
//!
//! Reference: Apple SMB Extensions protocol (MS-SMB2 §2.2.13.2)
//! and Samba MacExtensions.h implementation.
pub const AFP_INFO_SIZE: usize = 60;
pub const AFP_SIGNATURE: u32 = 0x41465000; // "AFP\0"
pub const AFP_VERSION: u32 = 0x00010000;
pub const AFP_OFF_FINDER_INFO: usize = 16;
pub const AFP_FINDER_SIZE: usize = 32;
pub const AFPINFO_STREAM_NAME: &[u8] = b"AFP_AfpInfo";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FinderInfo {
pub file_type: [u8; 4],
pub creator_type: [u8; 4],
pub finder_flags: u16,
pub location: (u16, u16),
pub reserved: u16,
pub extended_flags: u16,
pub extended_location: (u16, u16),
pub reserved2: [u8; 10],
}
impl FinderInfo {
pub fn default_file() -> Self {
Self {
file_type: [0, 0, 0, 0],
creator_type: [0, 0, 0, 0],
finder_flags: 0,
location: (0, 0),
reserved: 0,
extended_flags: 0,
extended_location: (0, 0),
reserved2: [0; 10],
}
}
pub fn default_directory() -> Self {
Self {
file_type: [0, 0, 0, 0],
creator_type: [0, 0, 0, 0],
finder_flags: 0,
location: (0, 0),
reserved: 0,
extended_flags: 0,
extended_location: (0, 0),
reserved2: [0; 10],
}
}
pub fn from_bytes(data: &[u8; 32]) -> Self {
let file_type = [data[0], data[1], data[2], data[3]];
let creator_type = [data[4], data[5], data[6], data[7]];
let finder_flags = u16::from_be_bytes([data[8], data[9]]);
let location = (
u16::from_be_bytes([data[10], data[11]]),
u16::from_be_bytes([data[12], data[13]]),
);
let reserved = u16::from_be_bytes([data[14], data[15]]);
let extended_flags = u16::from_be_bytes([data[16], data[17]]);
let extended_location = (
u16::from_be_bytes([data[18], data[19]]),
u16::from_be_bytes([data[20], data[21]]),
);
let reserved2 = [
data[22], data[23], data[24], data[25],
data[26], data[27], data[28], data[29],
data[30], data[31],
];
Self {
file_type,
creator_type,
finder_flags,
location,
reserved,
extended_flags,
extended_location,
reserved2,
}
}
pub fn to_bytes(&self) -> [u8; 32] {
let mut data = [0u8; 32];
data[0..4].copy_from_slice(&self.file_type);
data[4..8].copy_from_slice(&self.creator_type);
data[8..10].copy_from_slice(&self.finder_flags.to_be_bytes());
data[10..12].copy_from_slice(&self.location.0.to_be_bytes());
data[12..14].copy_from_slice(&self.location.1.to_be_bytes());
data[14..16].copy_from_slice(&self.reserved.to_be_bytes());
data[16..18].copy_from_slice(&self.extended_flags.to_be_bytes());
data[18..20].copy_from_slice(&self.extended_location.0.to_be_bytes());
data[20..22].copy_from_slice(&self.extended_location.1.to_be_bytes());
data[22..32].copy_from_slice(&self.reserved2);
data
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AfpInfo {
pub signature: u32,
pub version: u32,
pub reserved1: u32,
pub backup_time: u32,
pub finder_info: FinderInfo,
pub prodos_info: [u8; 6],
pub reserved2: [u8; 6],
}
impl AfpInfo {
pub fn new() -> Self {
Self {
signature: AFP_SIGNATURE,
version: AFP_VERSION,
reserved1: 0,
backup_time: 0,
finder_info: FinderInfo::default_file(),
prodos_info: [0; 6],
reserved2: [0; 6],
}
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < AFP_INFO_SIZE {
return None;
}
let signature = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
if signature != AFP_SIGNATURE {
return None;
}
let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
if version != AFP_VERSION {
return None;
}
let reserved1 = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
let backup_time = u32::from_le_bytes([data[12], data[13], data[14], data[15]]);
let finder_bytes: [u8; 32] = data[16..48].try_into().ok()?;
let finder_info = FinderInfo::from_bytes(&finder_bytes);
let prodos_info: [u8; 6] = data[48..54].try_into().ok()?;
let reserved2: [u8; 6] = data[54..60].try_into().ok()?;
Some(Self {
signature,
version,
reserved1,
backup_time,
finder_info,
prodos_info,
reserved2,
})
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut data = Vec::with_capacity(AFP_INFO_SIZE);
data.extend_from_slice(&self.signature.to_le_bytes());
data.extend_from_slice(&self.version.to_le_bytes());
data.extend_from_slice(&self.reserved1.to_le_bytes());
data.extend_from_slice(&self.backup_time.to_le_bytes());
data.extend_from_slice(&self.finder_info.to_bytes());
data.extend_from_slice(&self.prodos_info);
data.extend_from_slice(&self.reserved2);
data
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SambaAfpInfo {
pub afp: AfpInfo,
pub create_time: u32,
}
impl SambaAfpInfo {
pub fn new() -> Self {
Self {
afp: AfpInfo::new(),
create_time: 0,
}
}
pub fn from_bytes(data: &[u8]) -> Option<Self> {
if data.len() < AFP_INFO_SIZE + 4 {
return None;
}
let afp = AfpInfo::from_bytes(&data[..AFP_INFO_SIZE])?;
let create_time = u32::from_le_bytes([
data[60], data[61], data[62], data[63],
]);
Some(Self {
afp,
create_time,
})
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut data = self.afp.to_bytes();
data.extend_from_slice(&self.create_time.to_le_bytes());
data
}
pub fn get_create_time(&self) -> u32 {
self.create_time
}
pub fn set_create_time(&mut self, time: u32) {
self.create_time = time;
}
pub fn get_finder_info(&self) -> &FinderInfo {
&self.afp.finder_info
}
pub fn set_finder_info(&mut self, finder: FinderInfo) {
self.afp.finder_info = finder;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_afp_info_roundtrip() {
let afp = AfpInfo::new();
let bytes = afp.to_bytes();
assert_eq!(bytes.len(), AFP_INFO_SIZE);
let decoded = AfpInfo::from_bytes(&bytes).unwrap();
assert_eq!(decoded.signature, AFP_SIGNATURE);
assert_eq!(decoded.version, AFP_VERSION);
}
#[test]
fn test_samba_afp_info_roundtrip() {
let mut samba = SambaAfpInfo::new();
samba.set_create_time(12345678);
let bytes = samba.to_bytes();
assert_eq!(bytes.len(), AFP_INFO_SIZE + 4);
let decoded = SambaAfpInfo::from_bytes(&bytes).unwrap();
assert_eq!(decoded.get_create_time(), 12345678);
}
#[test]
fn test_finder_info_roundtrip() {
let finder = FinderInfo::default_file();
let bytes = finder.to_bytes();
assert_eq!(bytes.len(), 32);
let decoded = FinderInfo::from_bytes(&bytes);
assert_eq!(decoded.file_type, finder.file_type);
assert_eq!(decoded.creator_type, finder.creator_type);
}
#[test]
fn test_invalid_signature() {
let data = [0u8; 60];
assert!(AfpInfo::from_bytes(&data).is_none());
}
}

View File

@@ -171,6 +171,7 @@ impl CreateContext {
pub const NAME_RQLS: &'static [u8; 4] = b"RqLs"; // REQUEST_LEASE
pub const NAME_DH2Q: &'static [u8; 4] = b"DH2Q"; // DURABLE_HANDLE_REQUEST_V2
pub const NAME_DH2C: &'static [u8; 4] = b"DH2C"; // DURABLE_HANDLE_RECONNECT_V2
pub const NAME_AAPL: &'static [u8; 4] = b"AAPL"; // Apple SMB Extensions (MS-SMB2 §2.2.13.2)
/// Parse a chain of create-contexts from the raw chain bytes.
///

View File

@@ -7,6 +7,8 @@
//! The crate does **not** implement command behavior — it only encodes/decodes
//! the wire bytes. The server crate owns dispatch and state.
pub mod aapl;
pub mod afp_info;
pub mod cancel;
pub mod change_notify;
pub mod close;
@@ -29,6 +31,18 @@ pub mod tree_connect;
pub mod tree_disconnect;
pub mod write;
pub use aapl::{
AaplCreateContextRequest, AaplCreateContextResponse, SMB2_CRTCTX_AAPL_CASE_SENSITIVE,
SMB2_CRTCTX_AAPL_FULL_SYNC, SMB2_CRTCTX_AAPL_MODEL_INFO, SMB2_CRTCTX_AAPL_SERVER_CAPS,
SMB2_CRTCTX_AAPL_SERVER_QUERY, SMB2_CRTCTX_AAPL_SUPPORT_RESOLVE_ID,
SMB2_CRTCTX_AAPL_SUPPORTS_NFS_ACE, SMB2_CRTCTX_AAPL_SUPPORTS_OSX_COPYFILE,
SMB2_CRTCTX_AAPL_SUPPORTS_READ_DIR_ATTR, SMB2_CRTCTX_AAPL_UNIX_BASED,
SMB2_CRTCTX_AAPL_VOLUME_CAPS,
};
pub use afp_info::{
AfpInfo, FinderInfo, SambaAfpInfo, AFP_FINDER_SIZE, AFP_INFO_SIZE, AFP_OFF_FINDER_INFO,
AFP_SIGNATURE, AFP_VERSION, AFPINFO_STREAM_NAME,
};
pub use cancel::CancelRequest;
pub use change_notify::{ChangeNotifyRequest, ChangeNotifyResponse};
pub use close::{CloseRequest, CloseResponse};