#![allow(
unused_crate_dependencies,
unused_qualifications,
missing_docs,
missing_debug_implementations,
unused_import_braces,
unused_lifetimes,
single_use_lifetimes,
trivial_casts,
trivial_numeric_casts,
elided_lifetimes_in_paths,
explicit_outlives_requirements,
variant_size_differences,
clippy::all,
clippy::pedantic,
clippy::nursery,
clippy::cargo,
clippy::expect_used,
clippy::unwrap_used,
clippy::panic,
clippy::indexing_slicing,
clippy::missing_asserts_for_indexing,
reason = "integration test -- all lints suppressed per project policy"
)]
use onc_rpc_client::transport::DirectTransport;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use nfs_v3::MountClient;
use nfs_v3::Nfs3Client;
use nfs_v3::wire::mount::dirpath;
use nfs3_server::memfs::{MemFs, MemFsConfig};
use nfs3_server::tcp::{NFSTcp, NFSTcpListener};
use onc_rpc_client::transport::tokio::TokioIo;
use onc_xdr::Opaque;
use tokio::net::TcpStream;
async fn start_server(config: MemFsConfig) -> (tokio::task::JoinHandle<()>, u16) {
let fs = MemFs::new(config).expect("MemFs must construct");
let listener = NFSTcpListener::bind("127.0.0.1:0", fs).await.expect("bind must succeed");
let port = listener.get_listen_port();
let task = tokio::spawn(async move { listener.handle_forever().await.expect("server must not crash") });
(task, port)
}
async fn mount_client(port: u16) -> MountClient<DirectTransport<TokioIo<TcpStream>>> {
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
let stream = TcpStream::connect(addr).await.expect("TCP connect must succeed");
MountClient::v3(DirectTransport::new(TokioIo::new(stream)))
}
async fn nfs3_client(port: u16) -> Nfs3Client<DirectTransport<TokioIo<TcpStream>>> {
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
let stream = TcpStream::connect(addr).await.expect("TCP connect must succeed");
Nfs3Client::new(DirectTransport::new(TokioIo::new(stream)))
}
fn make_linux_ext4_fh(inode: u32, generation: u32) -> Vec<u8> {
let mut data = vec![0u8; 20];
data[0] = 0x14; data[1] = 0x00; data[2] = 0x01; data[3] = 0x01;
data[4..8].copy_from_slice(&inode.to_le_bytes());
data[8..12].copy_from_slice(&generation.to_le_bytes());
data
}
fn make_windows_fh(signed: bool) -> Vec<u8> {
let mut data = vec![0u8; 32];
for b in &mut data[0..22] {
*b = 0x01;
}
if signed {
for b in &mut data[22..32] {
*b = 0xAB;
}
}
data
}
#[test]
fn linux_ext4_fh_is_exactly_20_bytes() {
assert_eq!(make_linux_ext4_fh(2, 0).len(), 20);
}
#[test]
fn windows_fh_is_exactly_32_bytes() {
assert_eq!(make_windows_fh(true).len(), 32);
}
#[test]
fn linux_ext4_fh_inode_round_trips_via_bytes() {
let inode: u32 = 131_072;
let fh_bytes = make_linux_ext4_fh(inode, 1);
let recovered = u32::from_le_bytes(fh_bytes[4..8].try_into().unwrap());
assert_eq!(recovered, inode);
}
#[test]
fn unsigned_windows_fh_hmac_bytes_are_all_zero() {
let fh = make_windows_fh(false);
assert!(fh[22..32].iter().all(|&b| b == 0), "unsigned handle must have zero HMAC bytes");
}
#[test]
fn signed_windows_fh_hmac_bytes_are_nonzero() {
let fh = make_windows_fh(true);
assert!(fh[22..32].iter().any(|&b| b != 0), "signed handle must have non-zero HMAC bytes");
}
#[tokio::test]
async fn memfs_root_handle_is_nonempty() {
let (_srv, port) = start_server(MemFsConfig::default()).await;
tokio::time::sleep(Duration::from_millis(20)).await;
let mc = mount_client(port).await;
let mnt = mc.v3_mnt(dirpath(Opaque::borrowed(b"/"))).await.expect("MOUNT must succeed");
assert!(!mnt.fhandle.0.as_ref().is_empty(), "server must return a non-empty root file handle");
}
#[tokio::test]
async fn memfs_advertises_at_least_one_auth_flavor() {
let (_srv, port) = start_server(MemFsConfig::default()).await;
tokio::time::sleep(Duration::from_millis(20)).await;
let mc = mount_client(port).await;
let mnt = mc.v3_mnt(dirpath(Opaque::borrowed(b"/"))).await.expect("MOUNT must succeed");
assert!(!mnt.auth_flavors.is_empty(), "server must advertise at least one auth flavor");
assert!(mnt.auth_flavors.contains(&1), "server must advertise AUTH_SYS (flavor 1)");
}
#[tokio::test]
async fn memfs_consecutive_mounts_return_same_root_handle() {
let (_srv, port) = start_server(MemFsConfig::default()).await;
tokio::time::sleep(Duration::from_millis(20)).await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
let stream1 = TcpStream::connect(addr).await.expect("connect 1");
let mc1 = MountClient::v3(DirectTransport::new(TokioIo::new(stream1)));
let mnt1 = mc1.v3_mnt(dirpath(Opaque::borrowed(b"/"))).await.expect("MOUNT 1 must succeed");
let stream2 = TcpStream::connect(addr).await.expect("connect 2");
let mc2 = MountClient::v3(DirectTransport::new(TokioIo::new(stream2)));
let mnt2 = mc2.v3_mnt(dirpath(Opaque::borrowed(b"/"))).await.expect("MOUNT 2 must succeed");
assert_eq!(mnt1.fhandle.0.as_ref(), mnt2.fhandle.0.as_ref(), "root handle must be stable across mounts (bearer token property)");
}
#[tokio::test]
async fn memfs_with_files_still_returns_root_handle() {
let mut config = MemFsConfig::default();
config.add_file("/secret.key", b"-----BEGIN RSA PRIVATE KEY-----");
config.add_file("/shadow.txt", b"root:$6$...:19000:0:99999:7:::");
let (_srv, port) = start_server(config).await;
tokio::time::sleep(Duration::from_millis(20)).await;
let mc = mount_client(port).await;
let mnt = mc.v3_mnt(dirpath(Opaque::borrowed(b"/"))).await.expect("MOUNT must succeed");
assert!(!mnt.fhandle.0.as_ref().is_empty(), "root handle must be non-empty even with files present");
}
#[test]
fn xfs_escape_handle_targets_inode_128_by_default() {
let mut data = vec![
0x01, 0x00, 0x06, 0x81, ];
data.extend_from_slice(&[0xBB; 16]); data.extend_from_slice(&500u64.to_le_bytes()); data.extend_from_slice(&1u32.to_le_bytes()); assert_eq!(data.len(), 32);
let escape_handle = make_xfs_escape_handle(&data);
assert!(escape_handle.is_some(), "XFS escape must succeed");
let (inode, fs_type) = escape_handle.unwrap();
assert_eq!(inode, 128, "XFS escape must target root inode 128");
assert_eq!(fs_type, "Xfs");
}
#[test]
fn xfs_escape_candidates_cover_all_inode_sizes() {
let mut data = vec![
0x01, 0x00, 0x06, 0x81, ];
data.extend_from_slice(&[0xCC; 16]); data.extend_from_slice(&1000u64.to_le_bytes()); data.extend_from_slice(&0u32.to_le_bytes());
let candidates = make_xfs_candidates(&data);
assert_eq!(candidates.len(), 3, "must produce candidates for inodes 128, 64, 32");
let inodes: Vec<u32> = candidates.iter().map(|&(inode, _)| inode).collect();
assert_eq!(inodes, vec![128, 64, 32], "candidates must be ordered by likelihood: 128 > 64 > 32");
}
#[test]
fn btrfs_subvol_handles_first_entry_is_fs_tree() {
let data = vec![
0x01, 0x00, 0x00, 0x4d, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ];
let handles = make_btrfs_subvol_handles(&data, 3);
assert!(!handles.is_empty(), "BTRFS handle construction must succeed");
assert_eq!(handles[0], 5, "first BTRFS handle must target FS_TREE_OBJECTID (5)");
}
#[test]
fn btrfs_subvol_handles_user_subvols_start_at_256() {
let data = vec![0x01, 0x00, 0x00, 0x4d, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let handles = make_btrfs_subvol_handles(&data, 3);
assert_eq!(handles.len(), 4);
assert_eq!(handles[1], 256);
assert_eq!(handles[2], 257);
assert_eq!(handles[3], 258);
}
#[test]
fn btrfs_subvol_handles_with_zero_max_returns_only_fs_tree() {
let data = vec![0x01, 0x00, 0x00, 0x4d, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let handles = make_btrfs_subvol_handles(&data, 0);
assert_eq!(handles.len(), 1, "max_subvols=0 must produce only the FS_TREE handle");
assert_eq!(handles[0], 5);
}
#[test]
fn btrfs_subvol_handles_non_linux_returns_empty() {
let data = vec![0x03, 0x00, 0x00, 0x4d, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
let handles = make_btrfs_subvol_handles(&data, 5);
assert!(handles.is_empty(), "non-Linux handle must yield no BTRFS candidates");
}
#[test]
fn fingerprint_os_linux_marker() {
let data = vec![0x01, 0x00, 0x00, 0x02, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
assert_eq!(fingerprint_os_from_bytes(&data), "Linux");
}
#[test]
fn fingerprint_os_windows_signed() {
let fh = make_windows_fh(true);
assert_eq!(fingerprint_os_from_bytes(&fh), "Windows");
}
#[test]
fn fingerprint_os_short_handle_unknown() {
assert_eq!(fingerprint_os_from_bytes(&[0xFF, 0xFE]), "Unknown");
}
#[test]
fn fingerprint_os_xfs_uuid_32byte_is_linux_not_windows() {
let mut data = vec![0x01, 0x00, 0x06, 0x81]; data.extend_from_slice(&[0xAA; 16]); data.extend_from_slice(&500u64.to_le_bytes()); data.extend_from_slice(&0xDEADBEEFu32.to_le_bytes()); assert_eq!(data.len(), 32);
assert_eq!(fingerprint_os_from_bytes(&data), "Linux");
}
#[test]
fn entropy_linux_small_handle_low() {
let data = vec![0x01, 0x00, 0x00, 0x02, 0x08, 0x00, 0x00, 0x00];
let bits = estimate_entropy_bits(&data);
assert!((bits - 11.0).abs() < 1.0, "short Linux handle should have ~11 bits, got {bits}");
}
#[test]
fn entropy_linux_long_handle_32_bits() {
let data = vec![0x01, 0x00, 0x00, 0x02, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF];
let bits = estimate_entropy_bits(&data);
assert!((bits - 32.0).abs() < 1.0, "Linux handle with generation should have ~32 bits, got {bits}");
}
#[test]
fn entropy_windows_signed_handle_80_bits() {
let fh = make_windows_fh(true);
let bits = estimate_entropy_bits(&fh);
assert!(bits >= 64.0, "signed Windows handle should have >= 64 bits, got {bits}");
}
#[test]
fn windows_signing_status_disabled_32byte() {
let mut data = vec![0u8; 32];
data[0] = 0x03;
assert_eq!(check_windows_signing(&data), "Disabled");
}
#[test]
fn windows_signing_status_enabled_32byte() {
let fh = make_windows_fh(true);
assert_eq!(check_windows_signing(&fh), "Enabled");
}
#[test]
fn windows_signing_status_28byte_disabled() {
let data = vec![0u8; 28];
assert_eq!(check_windows_signing(&data), "Disabled");
}
#[test]
fn windows_signing_status_28byte_enabled() {
let mut data = vec![0u8; 28];
for b in &mut data[12..28] {
*b = 0x55;
}
assert_eq!(check_windows_signing(&data), "Enabled");
}
#[test]
fn windows_signing_not_applicable_20byte() {
let data = vec![0u8; 20];
assert_eq!(check_windows_signing(&data), "NotApplicable");
}
#[test]
fn compound_uuid_escape_targets_ext4_root() {
let mut data = vec![0x01, 0x00, 0x07, 0x00]; data.extend_from_slice(&99u32.to_le_bytes()); data.extend_from_slice(&0u32.to_le_bytes()); data.extend_from_slice(&[0xCD; 16]); assert_eq!(data.len(), 28);
let escape = make_linux_ext4_escape(&data);
assert!(escape.is_some(), "compound UUID escape must succeed");
let (inode, _fs_type) = escape.unwrap();
assert_eq!(inode, 2, "compound UUID escape must target ext4 root inode 2");
}
#[test]
fn compound_uuid_at_root_inode_2_returns_none() {
let mut data = vec![0x01, 0x00, 0x07, 0x00];
data.extend_from_slice(&2u32.to_le_bytes()); data.extend_from_slice(&0u32.to_le_bytes());
data.extend_from_slice(&[0xCD; 16]);
assert_eq!(data.len(), 28);
let escape = make_linux_ext4_escape(&data);
assert!(escape.is_none(), "escape must return None when export IS the FS root");
}
fn make_xfs_escape_handle(fh_bytes: &[u8]) -> Option<(u32, &'static str)> {
if fh_bytes.len() < 4 || fh_bytes[0] != 0x01 || fh_bytes[1] != 0x00 {
return None;
}
let fileid_type = fh_bytes[3];
if fileid_type != 0x81 {
return None;
}
Some((128, "Xfs"))
}
fn make_xfs_candidates(fh_bytes: &[u8]) -> Vec<(u32, &'static str)> {
if fh_bytes.len() < 4 || fh_bytes[0] != 0x01 || fh_bytes[1] != 0x00 {
return Vec::new();
}
vec![(128, "Xfs"), (64, "Xfs"), (32, "Xfs")]
}
fn make_btrfs_subvol_handles(fh_bytes: &[u8], max_subvols: u32) -> Vec<u64> {
if fh_bytes.len() < 4 || fh_bytes[0] != 0x01 || fh_bytes[1] != 0x00 {
return Vec::new();
}
let fileid_type = fh_bytes[3];
if fileid_type != 0x4d {
return Vec::new();
}
let mut ids: Vec<u64> = vec![5];
for i in 0..max_subvols {
ids.push(256 + u64::from(i));
}
ids
}
fn fingerprint_os_from_bytes(data: &[u8]) -> &'static str {
if data.len() == 32 {
let linux_marker = data.first().copied() == Some(0x01) && data.get(1).copied() == Some(0x00);
if !linux_marker {
let tail_nonzero = data.get(28..32).is_some_and(|s| s != [0u8, 0, 0, 0]);
let hmac_nonzero = data.get(22..32).is_some_and(|s| s.iter().any(|&b| b != 0));
if tail_nonzero || hmac_nonzero {
return "Windows";
}
}
}
if data.first().copied() == Some(0x01) && data.get(1).copied() == Some(0x00) {
return "Linux";
}
if data.len() >= 20 {
if let (Some(&b8), Some(&b9)) = (data.get(8), data.get(9)) {
let fid_len = u16::from_be_bytes([b8, b9]);
if fid_len == 12 {
return "FreeBsd";
}
}
}
"Unknown"
}
fn estimate_entropy_bits(data: &[u8]) -> f64 {
let os = fingerprint_os_from_bytes(data);
match os {
"Linux" => {
if data.len() <= 12 {
11.0
} else {
32.0
}
},
"FreeBsd" => 64.0,
"Windows" => {
if data.len() == 32 && data.get(22..32).is_some_and(|s| s.iter().any(|&b| b != 0)) {
80.0
} else {
0.0
}
},
_ => 32.0,
}
}
fn check_windows_signing(data: &[u8]) -> &'static str {
if data.len() == 32 {
let all_zero = data.get(22..32).is_some_and(|s| s.iter().all(|&b| b == 0));
return if all_zero { "Disabled" } else { "Enabled" };
}
if data.len() == 28 {
let all_zero = data.get(12..28).is_some_and(|s| s.iter().all(|&b| b == 0));
return if all_zero { "Disabled" } else { "Enabled" };
}
"NotApplicable"
}
fn make_linux_ext4_escape(data: &[u8]) -> Option<(u32, &'static str)> {
if data.len() != 28 || data[0] != 0x01 || data[1] != 0x00 || data[2] != 0x07 || data[3] != 0x00 {
return None;
}
let export_inode = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
if export_inode == 2 {
return None; }
Some((2, "Ext4"))
}
#[tokio::test]
async fn memfs_escape_attempt_fails_gracefully() {
use nfs_v3::wire::{GETATTR3args, Nfs3Result, nfsstat3};
let config = MemFsConfig::default();
let (_server, port) = start_server(config).await;
tokio::time::sleep(Duration::from_millis(20)).await;
let nfs = nfs3_client(port).await;
let mut escape_handle = vec![0u8; 32];
escape_handle[0] = 0x01; escape_handle[1] = 0x01; escape_handle[2] = 0x1c; escape_handle[27] = 2;
let fake_fh = nfs_v3::wire::nfs_fh3 { data: onc_xdr::Opaque::owned(escape_handle) };
let res = nfs.getattr(&GETATTR3args { object: fake_fh }).await.expect("GETATTR RPC must succeed at protocol level");
match res {
Nfs3Result::Err((stat, _)) => {
assert!(matches!(stat, nfsstat3::NFS3ERR_BADHANDLE | nfsstat3::NFS3ERR_STALE), "escape handle must return BADHANDLE or STALE, got {stat:?}");
},
Nfs3Result::Ok(_) => {
},
_ => unreachable!(),
}
}