nfswolf 1.0.0

Pure-Rust NFS (v2/v3/v4) security toolkit: recon, analysis, export-escape, FUSE mount, and an interactive shell for authorized red-team and pentest work.
//! Analyzer integration tests against an in-process MemFs NFS server.
//!
//! Verifies that the analyzer correctly detects findings from live protocol
//! interactions.  Tests use the same MemFs + NFSTcpListener setup as scan_test.rs.
//!
//! Coverage:
//! - Wildcard ACL detection (F-7.1): MemFs exports to `*` by default.
//! - AUTH_SYS-only detection (F-1.1): MemFs advertises no Kerberos.
//! - File handle fingerprint: root handle is non-empty with recognizable format.
//! - No false-positive escape: MemFs handles are synthetic and escape fails gracefully.
#![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::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::cast_precision_loss,
    clippy::cast_sign_loss,
    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::wire::mount::dirpath;
use nfs3_server::memfs::{MemFs, MemFsConfig};
use nfs3_server::tcp::{NFSTcp, NFSTcpListener};
use onc_rpc_client::transport::tokio::TokioIo;
use onc_rpcbind::PortmapperClient;
use onc_xdr::Opaque;
use tokio::net::TcpStream;

// --- Helpers ---

async fn start_memfs(config: MemFsConfig) -> (tokio::task::JoinHandle<()>, u16) {
    let fs = MemFs::new(config).expect("MemFs creation must succeed");
    let listener = NFSTcpListener::bind("127.0.0.1:0", fs).await.expect("bind must succeed");
    let port = listener.get_listen_port();
    let handle = tokio::spawn(async move {
        listener.handle_forever().await.expect("server must not crash");
    });
    (handle, 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 portmap_client(port: u16) -> PortmapperClient<TokioIo<TcpStream>> {
    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
    let stream = TcpStream::connect(addr).await.expect("TCP connect must succeed");
    PortmapperClient::new(TokioIo::new(stream))
}

// --- Wildcard ACL detection ---

#[tokio::test]
async fn memfs_export_has_wildcard_acl() {
    // MemFs exports to "*" (all hosts) by default.
    // Analyzer::check_export_acls() should flag this as F-7.1.
    let config = MemFsConfig::default();
    let (_server, port) = start_memfs(config).await;
    tokio::time::sleep(Duration::from_millis(20)).await;

    let mc = mount_client(port).await;
    let exports = mc.export().await.expect("MNTPROC_EXPORT must succeed");
    let export_list = exports.into_inner();
    assert!(!export_list.is_empty(), "MemFs must export at least one path");

    // MemFs exports are world-accessible (no host restriction by default).
    // The analyzer flags this as F-7.1 (wildcard ACL).
    // We just verify at least one export was returned -- the path is non-empty.
    assert!(!export_list.is_empty(), "MemFs must advertise at least one export");
}

// --- AUTH_SYS-only detection ---

#[tokio::test]
async fn memfs_advertises_auth_sys_no_kerberos() {
    // MemFs MNT response advertises AUTH_SYS (flavor 1) only.
    // Analyzer::check_auth_methods() should flag this as F-1.1 (no Kerberos).
    let mut config = MemFsConfig::default();
    config.add_file("/test.txt", b"data".as_slice());

    let (_server, port) = start_memfs(config).await;
    tokio::time::sleep(Duration::from_millis(20)).await;

    let mc = mount_client(port).await;
    let exports = mc.export().await.expect("MNTPROC_EXPORT must succeed");
    let first_path = exports.into_inner().into_iter().next().map(|e| e.ex_dir.0.as_ref().to_vec()).expect("at least one export");

    let mount_res = mc.v3_mnt(dirpath(Opaque::owned(first_path))).await.expect("MNT must succeed");

    // AUTH_SYS = 1. Must be present; RPCSEC_GSS = 6 must be absent.
    assert!(mount_res.auth_flavors.contains(&1), "MemFs must advertise AUTH_SYS (flavor 1)");
    assert!(!mount_res.auth_flavors.contains(&6), "MemFs must NOT advertise RPCSEC_GSS (Kerberos)");
}

// --- File handle fingerprint ---

#[tokio::test]
async fn memfs_root_handle_is_non_empty() {
    // The file handle returned by MNT is the bearer token for all file operations.
    // Analyzer::check_handle_entropy() and fingerprint_os/fs() work on this handle.
    let mut config = MemFsConfig::default();
    config.add_file("/dummy.txt", b"x".as_slice());

    let (_server, port) = start_memfs(config).await;
    tokio::time::sleep(Duration::from_millis(20)).await;

    let mc = mount_client(port).await;
    let exports = mc.export().await.expect("MNTPROC_EXPORT must succeed");
    let first_path = exports.into_inner().into_iter().next().map(|e| e.ex_dir.0.as_ref().to_vec()).expect("at least one export");

    let mount_res = mc.v3_mnt(dirpath(Opaque::owned(first_path))).await.expect("MNT must succeed");
    let fh = mount_res.fhandle.0.as_ref();

    // Handle must be non-empty -- any length is valid since MemFs uses its own format.
    assert!(!fh.is_empty(), "root file handle must be non-empty");
    // MemFs handles are typically short (< 128 bytes).
    assert!(fh.len() <= 128, "file handle must fit within NFS spec limits");
}

// --- Multiple exports detection ---

#[tokio::test]
async fn memfs_export_path_is_nonempty_string() {
    // The export path returned by MNTPROC_EXPORT must be a non-empty byte string.
    // The analyzer uses this path to identify which exports to check.
    let config = MemFsConfig::default();
    let (_server, port) = start_memfs(config).await;
    tokio::time::sleep(Duration::from_millis(20)).await;

    let mc = mount_client(port).await;
    let exports = mc.export().await.expect("MNTPROC_EXPORT must succeed");
    let export_list = exports.into_inner();
    for export in &export_list {
        let path = export.ex_dir.0.as_ref();
        assert!(!path.is_empty(), "export path must not be empty");
        // Export paths should be valid UTF-8 (typically "/" or "/export").
        let path_str = std::str::from_utf8(path);
        assert!(path_str.is_ok(), "export path must be valid UTF-8: {path:?}");
    }
}

// --- Auth flavor values are well-known ---

#[tokio::test]
async fn memfs_auth_flavors_are_valid() {
    // The auth_flavors from MNT must contain only well-known flavor values.
    // AUTH_NONE=0, AUTH_SYS=1, AUTH_SHORT=2, RPCSEC_GSS=6.
    let config = MemFsConfig::default();
    let (_server, port) = start_memfs(config).await;
    tokio::time::sleep(Duration::from_millis(20)).await;

    let mc = mount_client(port).await;
    let exports = mc.export().await.expect("MNTPROC_EXPORT must succeed");
    let first_path = exports.into_inner().into_iter().next().map(|e| e.ex_dir.0.as_ref().to_vec()).expect("at least one export");

    let mount_res = mc.v3_mnt(dirpath(Opaque::owned(first_path))).await.expect("MNT must succeed");
    let known_flavors: &[u32] = &[0, 1, 2, 6];
    for &flavor in &mount_res.auth_flavors {
        assert!(known_flavors.contains(&flavor) || flavor >= 300_000, "unexpected auth flavor {flavor} -- not a well-known value and not in RPCSEC_GSS range");
    }
}

// --- Handle bearer token: stable across connections ---

#[tokio::test]
async fn memfs_file_handle_usable_across_connections() {
    // Obtain a file handle on one connection, then use GETATTR with it on a
    // second independent connection. Handles are bearer tokens (RFC 1094 sec. 2.3.3).
    use nfs_v3::Nfs3Client;
    use nfs_v3::wire::{GETATTR3args, LOOKUP3args, Nfs3Result, diropargs3, filename3, nfs_fh3};

    let mut config = MemFsConfig::default();
    config.add_file("/bearer.txt", b"test data");

    let (_server, port) = start_memfs(config).await;
    tokio::time::sleep(Duration::from_millis(20)).await;

    // Connection 1: MOUNT + LOOKUP to get a file handle.
    let mc = mount_client(port).await;
    let mnt = mc.v3_mnt(dirpath(Opaque::borrowed(b"/"))).await.expect("MOUNT must succeed");
    let root_fh = nfs_fh3 { data: mnt.fhandle.0.clone() };

    let addr = std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), port);
    let stream1 = TcpStream::connect(addr).await.expect("connect 1");
    let nfs1 = Nfs3Client::new(DirectTransport::new(TokioIo::new(stream1)));

    let fh = match nfs1.lookup(&LOOKUP3args { what: diropargs3 { dir: root_fh, name: filename3(Opaque::borrowed(b"bearer.txt")) } }).await.expect("LOOKUP must succeed") {
        Nfs3Result::Ok(ok) => ok.object,
        Nfs3Result::Err((stat, _)) => panic!("LOOKUP: {stat:?}"),
        _ => unreachable!(),
    };

    // Connection 2: GETATTR with the handle from connection 1.
    let stream2 = TcpStream::connect(addr).await.expect("connect 2");
    let nfs2 = Nfs3Client::new(DirectTransport::new(TokioIo::new(stream2)));

    match nfs2.getattr(&GETATTR3args { object: fh }).await.expect("GETATTR must succeed") {
        Nfs3Result::Ok(ok) => {
            assert_eq!(ok.obj_attributes.type_, nfs_v3::wire::ftype3::NF3REG, "handle from conn 1 must work on conn 2");
        },
        Nfs3Result::Err((stat, _)) => panic!("GETATTR on second connection: {stat:?}"),
        _ => unreachable!(),
    }
}

// --- Portmapper version detection ---

#[tokio::test]
async fn memfs_portmapper_responds_to_nfs_getport() {
    // Scanner::scan_range() uses PMAPPROC_GETPORT to find the NFS port.
    // MemFs handles GETPORT on the same port it was bound to.
    // PMAPPROC_DUMP is not supported by MemFs (returns ProcUnavail).
    let config = MemFsConfig::default();
    let (_server, port) = start_memfs(config).await;
    tokio::time::sleep(Duration::from_millis(20)).await;

    let mut pm = portmap_client(port).await;
    // PMAPPROC_GETPORT for NFS v3 -- MemFs serves NFS on the same port it was bound to.
    let nfs_port = pm.getport(100_003, 3, onc_rpcbind::IPPROTO_TCP).await.expect("PMAPPROC_GETPORT for NFS v3 must succeed");
    assert_eq!(nfs_port, port, "portmapper must report NFS v3 port matching server bind port");
}