nfs-rs 0.3.0

An asynchronous, pure Rust client library for NFSv3 and NFSv4.1
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
//! NFSv4.1 callback handling — server→client backchannel (RFC 8881 §2.10.3, §20).
//!
//! In NFSv4.1 the backchannel rides the *fore-channel* TCP connection: the server
//! sends CB_COMPOUND calls (CB_SEQUENCE, CB_RECALL) as ordinary RPC CALL messages
//! back over the same connection the client opened. The RPC layer's reader loop
//! detects those inbound CALLs and routes them to the handler built here via
//! [`make_backchannel_handler`]; the reply is written back on the same connection.
//!
//! There is deliberately no separate callback listener socket — that is the
//! NFSv4.0 model (the server dials back to a client-advertised address) and is
//! unnecessary and unused in NFSv4.1.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use bytes::{Buf, Bytes};
use tokio::sync::mpsc;
use tracing::{debug, warn};

use crate::error::{NfsError, Result};
use crate::rpc::BackchannelHandler;

/// Maximum number of operations in a CB_COMPOUND.
const MAX_CB_OPS: u32 = 64;
/// Maximum referring_call_lists entries.
const MAX_REFERRING_LISTS: usize = 256;

/// Callback program number (client-chosen, communicated in CREATE_SESSION).
pub(crate) const CB_PROGRAM: u32 = 0x40000000;

/// NFS4ERR_SEQ_MISORDERED error code (RFC 8881).
const NFS4ERR_SEQ_MISORDERED: u32 = 10063;

/// A recall notification from the server (delegation or pNFS layout).
#[derive(Debug, Clone)]
pub(crate) enum RecallNotification {
    /// CB_RECALL:delegation 召回(RFC 8881 §20.2)。
    Delegation {
        /// The stateid of the delegation being recalled.
        stateid: [u8; 16],
        /// Whether to truncate the file (only for write delegations).
        /// RFC 8881 §20.2.1 — not yet acted upon; retained for future use.
        #[allow(dead_code)]
        truncate: bool,
        /// The file handle of the file whose delegation is recalled.
        fh: Bytes,
    },
    /// CB_LAYOUTRECALL4_FILE:单文件 layout 召回(RFC 8881 §20.3)。
    LayoutFile {
        /// 召回报文携带的 layout stateid,LAYOUTRETURN 时原样使用。
        stateid: [u8; 16],
        fh: Bytes,
        offset: u64,
        length: u64,
        iomode: u32,
    },
    /// CB_LAYOUTRECALL4_FSID / _ALL:召回客户端持有的全部 layout。
    LayoutAll,
}

/// Build the backchannel handler installed into the RPC layer for this session.
///
/// The returned closure is invoked by the reader loop for every inbound server
/// CB_COMPOUND CALL. It owns the per-connection backchannel slot table used to
/// validate CB_SEQUENCE sequencing (RFC 8881 §2.10.6.3).
pub(crate) fn make_backchannel_handler(
    session_id: [u8; 16],
    recall_tx: mpsc::Sender<RecallNotification>,
) -> BackchannelHandler {
    // slot_id → next expected sequence ID (server must use 1 on first use of a slot).
    let slot_seqs: Arc<Mutex<HashMap<u32, u32>>> = Arc::new(Mutex::new(HashMap::new()));
    Arc::new(move |frame: Bytes| {
        let mut buf = frame;
        handle_cb_compound(&mut buf, &session_id, &recall_tx, &slot_seqs)
    })
}

/// Parse a CB_COMPOUND RPC CALL and produce the full RPC reply frame (starting at
/// the xid, without the record mark). Returns `None` if the message cannot be
/// parsed, in which case the reader loop drops it.
fn handle_cb_compound(
    buf: &mut Bytes,
    session_id: &[u8; 16],
    recall_tx: &mpsc::Sender<RecallNotification>,
    slot_seqs: &Mutex<HashMap<u32, u32>>,
) -> Option<Vec<u8>> {
    match parse_cb_compound(buf, session_id, recall_tx, slot_seqs) {
        Ok(reply) => Some(reply),
        Err(e) => {
            warn!(error = %e, "failed to handle CB_COMPOUND");
            None
        }
    }
}

fn parse_cb_compound(
    buf: &mut Bytes,
    session_id: &[u8; 16],
    recall_tx: &mpsc::Sender<RecallNotification>,
    slot_seqs: &Mutex<HashMap<u32, u32>>,
) -> Result<Vec<u8>> {
    // RPC call header: xid(4) + msg_type(4) + rpc_version(4) + program(4) + version(4) + procedure(4)
    if buf.remaining() < 24 {
        return Err(NfsError::Xdr("CB RPC header too short".to_string()));
    }
    let xid = buf.get_u32();
    let _msg_type = buf.get_u32(); // 0 = CALL
    let _rpc_vers = buf.get_u32();
    let _program = buf.get_u32();
    let _version = buf.get_u32();
    let procedure = buf.get_u32();

    // Skip auth (cred + verf)
    skip_rpc_auth(buf)?;
    skip_rpc_auth(buf)?;

    // CB_COMPOUND (procedure 1)
    if procedure != 1 {
        // CB_NULL (procedure 0) — just return success
        return Ok(build_rpc_reply(xid, &[]));
    }

    // Parse CB_COMPOUND4args: tag + minorversion + callback_ident + ops
    let _tag = skip_opaque(buf)?;
    if buf.remaining() < 8 {
        return Err(NfsError::Xdr("CB_COMPOUND args truncated".to_string()));
    }
    let _minor_version = buf.get_u32();
    let _callback_ident = buf.get_u32();

    if buf.remaining() < 4 {
        return Err(NfsError::Xdr("CB_COMPOUND ops count truncated".to_string()));
    }
    let num_ops = buf.get_u32();
    // Bound num_ops to prevent CPU exhaustion
    if num_ops > MAX_CB_OPS {
        return Err(NfsError::Xdr(format!(
            "CB_COMPOUND has {} ops, max {}",
            num_ops, MAX_CB_OPS
        )));
    }

    let mut reply_ops = Vec::new();

    for _ in 0..num_ops {
        if buf.remaining() < 4 {
            break;
        }
        let opcode = buf.get_u32();

        match opcode {
            // CB_SEQUENCE
            11 => {
                // CB_SEQUENCE4args: sessionid(16) + sequenceid(4) + slotid(4) + highest_slotid(4) + cachethis(4) + referring_call_lists
                if buf.remaining() < 32 {
                    break;
                }
                let mut cb_session_id = [0u8; 16];
                buf.copy_to_slice(&mut cb_session_id);
                let cb_sequenceid = buf.get_u32();
                let cb_slotid = buf.get_u32();
                let cb_highest_slotid = buf.get_u32();
                let _cachethis = buf.get_u32();

                // referring_call_lists<>
                if buf.remaining() >= 4 {
                    let n = buf.get_u32() as usize;
                    if n > MAX_REFERRING_LISTS {
                        return Err(NfsError::Xdr(format!(
                            "too many referring_call_lists: {}",
                            n
                        )));
                    }
                    for _ in 0..n {
                        if buf.remaining() < 16 {
                            return Err(NfsError::Xdr(
                                "referring_call sessionid truncated".to_string(),
                            ));
                        }
                        buf.advance(16);
                        if buf.remaining() < 4 {
                            return Err(NfsError::Xdr(
                                "referring_call count truncated".to_string(),
                            ));
                        }
                        let m = buf.get_u32() as usize;
                        let needed = m
                            .checked_mul(8)
                            .ok_or_else(|| NfsError::Xdr("referring_call overflow".to_string()))?;
                        if buf.remaining() < needed {
                            return Err(NfsError::Xdr("referring_call data truncated".to_string()));
                        }
                        buf.advance(needed);
                    }
                }

                // Validate session ID matches our session
                if cb_session_id != *session_id {
                    warn!("CB_SEQUENCE session ID mismatch, ignoring");
                    return Err(NfsError::Xdr("CB_SEQUENCE session ID mismatch".to_string()));
                }

                // RFC 8881 §2.10.6.3: validate sequence ID against our slot table.
                // Server MUST use sequenceid=1 on first use of a slot.
                let expected_seq = {
                    let map = slot_seqs
                        .lock()
                        .map_err(|_| NfsError::Rpc("cb slot table lock poisoned".to_string()))?;
                    map.get(&cb_slotid).copied().unwrap_or(1)
                };
                if cb_sequenceid != expected_seq {
                    warn!(
                        cb_slotid,
                        cb_sequenceid, expected_seq, "CB_SEQUENCE misordered — rejecting"
                    );
                    let mut op_reply = Vec::new();
                    op_reply.extend_from_slice(&opcode.to_be_bytes());
                    op_reply.extend_from_slice(&NFS4ERR_SEQ_MISORDERED.to_be_bytes());
                    reply_ops.push(op_reply);
                    break; // RFC: stop processing after first error
                }
                {
                    let mut map = slot_seqs
                        .lock()
                        .map_err(|_| NfsError::Rpc("cb slot table lock poisoned".to_string()))?;
                    map.insert(cb_slotid, cb_sequenceid.wrapping_add(1));
                }

                // Reply: CB_SEQUENCE4resok
                let mut op_reply = Vec::new();
                op_reply.extend_from_slice(&opcode.to_be_bytes());
                op_reply.extend_from_slice(&0u32.to_be_bytes()); // NFS4_OK
                op_reply.extend_from_slice(&cb_session_id);
                op_reply.extend_from_slice(&cb_sequenceid.to_be_bytes());
                op_reply.extend_from_slice(&cb_slotid.to_be_bytes());
                op_reply.extend_from_slice(&cb_highest_slotid.to_be_bytes());
                op_reply.extend_from_slice(&cb_highest_slotid.to_be_bytes()); // target_highest_slotid
                reply_ops.push(op_reply);

                debug!(
                    slotid = cb_slotid,
                    seqid = cb_sequenceid,
                    "CB_SEQUENCE handled"
                );
            }

            // CB_RECALL
            4 => {
                // CB_RECALL4args: stateid(16) + truncate(4) + fh(var)
                if buf.remaining() < 20 {
                    break;
                }
                let mut stateid = [0u8; 16];
                buf.copy_to_slice(&mut stateid);
                let truncate = buf.get_u32() != 0;
                let fh = read_opaque(buf)?;

                debug!(fh_len = fh.len(), truncate, "CB_RECALL received");

                // Notify the delegation manager
                if let Err(e) = recall_tx.try_send(RecallNotification::Delegation {
                    stateid,
                    truncate,
                    fh,
                }) {
                    warn!("CB_RECALL notification channel full or closed: {}", e);
                }

                // Reply: CB_RECALL4res = NFS4_OK
                let mut op_reply = Vec::new();
                op_reply.extend_from_slice(&opcode.to_be_bytes());
                op_reply.extend_from_slice(&0u32.to_be_bytes()); // NFS4_OK
                reply_ops.push(op_reply);
            }

            // CB_LAYOUTRECALL (RFC 8881 §20.3)
            5 => {
                // CB_LAYOUTRECALL4args: clora_type(4) + clora_iomode(4) + clora_changed(4)
                //                       + lor_recalltype(4) + union body
                if buf.remaining() < 16 {
                    break;
                }
                let _layout_type = buf.get_u32();
                let iomode = buf.get_u32();
                let _changed = buf.get_u32();
                let recalltype = buf.get_u32();

                let notification = match recalltype {
                    // LAYOUTRECALL4_FILE: fh<> + offset(8) + length(8) + stateid(16)
                    1 => {
                        let fh = read_opaque(buf)?;
                        if buf.remaining() < 32 {
                            return Err(NfsError::Xdr(
                                "CB_LAYOUTRECALL file args truncated".to_string(),
                            ));
                        }
                        let offset = buf.get_u64();
                        let length = buf.get_u64();
                        let mut stateid = [0u8; 16];
                        buf.copy_to_slice(&mut stateid);
                        Some(RecallNotification::LayoutFile {
                            stateid,
                            fh,
                            offset,
                            length,
                            iomode,
                        })
                    }
                    // LAYOUTRECALL4_FSID: fsid4 = major(8) + minor(8)。
                    // 客户端不跟踪 fsid 与 layout 的映射,保守地全量归还。
                    2 => {
                        if buf.remaining() < 16 {
                            return Err(NfsError::Xdr(
                                "CB_LAYOUTRECALL fsid truncated".to_string(),
                            ));
                        }
                        buf.advance(16);
                        Some(RecallNotification::LayoutAll)
                    }
                    // LAYOUTRECALL4_ALL: no body
                    3 => Some(RecallNotification::LayoutAll),
                    _ => None,
                };

                match notification {
                    Some(n) => {
                        debug!(recalltype, iomode, "CB_LAYOUTRECALL received");
                        if let Err(e) = recall_tx.try_send(n) {
                            warn!("CB_LAYOUTRECALL notification channel full or closed: {}", e);
                        }
                        // 先回 NFS4_OK,由 recall handler 异步驱逐缓存并 LAYOUTRETURN
                        // (RFC 8881 §12.5.5.1:OK 表示客户端承诺随后归还)
                        let mut op_reply = Vec::new();
                        op_reply.extend_from_slice(&opcode.to_be_bytes());
                        op_reply.extend_from_slice(&0u32.to_be_bytes()); // NFS4_OK
                        reply_ops.push(op_reply);
                    }
                    None => {
                        let mut op_reply = Vec::new();
                        op_reply.extend_from_slice(&opcode.to_be_bytes());
                        op_reply.extend_from_slice(&10022u32.to_be_bytes()); // NFS4ERR_INVAL
                        reply_ops.push(op_reply);
                        debug!(recalltype, "CB_LAYOUTRECALL unknown recalltype");
                        break; // RFC: stop processing after first error
                    }
                }
            }

            // Unknown callback op — return NFS4ERR_OP_ILLEGAL
            _ => {
                let mut op_reply = Vec::new();
                op_reply.extend_from_slice(&opcode.to_be_bytes());
                op_reply.extend_from_slice(&10044u32.to_be_bytes()); // NFS4ERR_OP_ILLEGAL
                reply_ops.push(op_reply);
                debug!(opcode, "unknown callback op, returning OP_ILLEGAL");
            }
        }
    }

    // Build CB_COMPOUND4res: status(4) + tag(var) + resarray
    let mut compound_res = Vec::new();
    compound_res.extend_from_slice(&0u32.to_be_bytes()); // NFS4_OK
    compound_res.extend_from_slice(&0u32.to_be_bytes()); // empty tag
    compound_res.extend_from_slice(&(reply_ops.len() as u32).to_be_bytes());
    for op in &reply_ops {
        compound_res.extend_from_slice(op);
    }

    Ok(build_rpc_reply(xid, &compound_res))
}

/// Build a minimal RPC reply message (starting at the xid; no record mark).
fn build_rpc_reply(xid: u32, body: &[u8]) -> Vec<u8> {
    let mut reply = Vec::with_capacity(24 + body.len());
    reply.extend_from_slice(&xid.to_be_bytes()); // xid
    reply.extend_from_slice(&1u32.to_be_bytes()); // msg_type = REPLY
    reply.extend_from_slice(&0u32.to_be_bytes()); // reply_stat = MSG_ACCEPTED
    // Verf: AUTH_NONE
    reply.extend_from_slice(&0u32.to_be_bytes()); // flavor = AUTH_NONE
    reply.extend_from_slice(&0u32.to_be_bytes()); // body length = 0
    reply.extend_from_slice(&0u32.to_be_bytes()); // accept_stat = SUCCESS
    reply.extend_from_slice(body);
    reply
}

fn skip_rpc_auth(buf: &mut Bytes) -> Result<()> {
    if buf.remaining() < 8 {
        return Err(NfsError::Xdr("RPC auth truncated".to_string()));
    }
    let _flavor = buf.get_u32();
    let len = buf.get_u32() as usize;
    let padded = (len + 3) & !3;
    if buf.remaining() < padded {
        return Err(NfsError::Xdr("RPC auth body truncated".to_string()));
    }
    buf.advance(padded);
    Ok(())
}

fn skip_opaque(buf: &mut Bytes) -> Result<usize> {
    if buf.remaining() < 4 {
        return Err(NfsError::Xdr("opaque length truncated".to_string()));
    }
    let len = buf.get_u32() as usize;
    let padded = (len + 3) & !3;
    if buf.remaining() < padded {
        return Err(NfsError::Xdr("opaque data truncated".to_string()));
    }
    buf.advance(padded);
    Ok(len)
}

fn read_opaque(buf: &mut Bytes) -> Result<Bytes> {
    if buf.remaining() < 4 {
        return Err(NfsError::Xdr("opaque length truncated".to_string()));
    }
    let len = buf.get_u32() as usize;
    let padded = (len + 3) & !3;
    if buf.remaining() < padded {
        return Err(NfsError::Xdr("opaque data truncated".to_string()));
    }
    let data = buf.slice(..len);
    buf.advance(padded);
    Ok(data)
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    fn be(v: u32) -> [u8; 4] {
        v.to_be_bytes()
    }

    fn u32_at(b: &[u8], off: usize) -> u32 {
        u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
    }

    /// Build a CB_COMPOUND RPC CALL carrying a single CB_SEQUENCE op.
    fn cb_sequence_frame(xid: u32, session_id: &[u8; 16], slotid: u32, seqid: u32) -> Bytes {
        let mut f = Vec::new();
        f.extend_from_slice(&be(xid)); // xid
        f.extend_from_slice(&be(0)); // msg_type = CALL
        f.extend_from_slice(&be(2)); // rpc_version
        f.extend_from_slice(&be(CB_PROGRAM)); // program
        f.extend_from_slice(&be(1)); // version
        f.extend_from_slice(&be(1)); // procedure = CB_COMPOUND
        f.extend_from_slice(&be(0)); // cred flavor = AUTH_NONE
        f.extend_from_slice(&be(0)); // cred len = 0
        f.extend_from_slice(&be(0)); // verf flavor = AUTH_NONE
        f.extend_from_slice(&be(0)); // verf len = 0
        f.extend_from_slice(&be(0)); // tag len = 0
        f.extend_from_slice(&be(1)); // minorversion
        f.extend_from_slice(&be(0)); // callback_ident
        f.extend_from_slice(&be(1)); // num_ops
        f.extend_from_slice(&be(11)); // opcode = CB_SEQUENCE
        f.extend_from_slice(session_id); // sessionid
        f.extend_from_slice(&be(seqid)); // sequenceid
        f.extend_from_slice(&be(slotid)); // slotid
        f.extend_from_slice(&be(slotid)); // highest_slotid
        f.extend_from_slice(&be(0)); // cachethis
        f.extend_from_slice(&be(0)); // referring_call_lists count
        Bytes::from(f)
    }

    /// Build a CB_COMPOUND RPC CALL carrying a single CB_RECALL op.
    fn cb_recall_frame(xid: u32, stateid: &[u8; 16], truncate: bool, fh: &[u8]) -> Bytes {
        let mut f = Vec::new();
        f.extend_from_slice(&be(xid));
        f.extend_from_slice(&be(0)); // CALL
        f.extend_from_slice(&be(2));
        f.extend_from_slice(&be(CB_PROGRAM));
        f.extend_from_slice(&be(1));
        f.extend_from_slice(&be(1)); // CB_COMPOUND
        f.extend_from_slice(&be(0)); // cred
        f.extend_from_slice(&be(0));
        f.extend_from_slice(&be(0)); // verf
        f.extend_from_slice(&be(0));
        f.extend_from_slice(&be(0)); // tag
        f.extend_from_slice(&be(1)); // minorversion
        f.extend_from_slice(&be(0)); // callback_ident
        f.extend_from_slice(&be(1)); // num_ops
        f.extend_from_slice(&be(4)); // opcode = CB_RECALL
        f.extend_from_slice(stateid); // stateid(16)
        f.extend_from_slice(&be(if truncate { 1 } else { 0 })); // truncate
        f.extend_from_slice(&be(fh.len() as u32)); // fh opaque len
        f.extend_from_slice(fh);
        let pad = (4 - fh.len() % 4) % 4;
        f.extend_from_slice(&[0u8; 4][..pad]);
        Bytes::from(f)
    }

    #[test]
    fn cb_sequence_ok_then_misordered() {
        let session_id = [7u8; 16];
        let (tx, _rx) = mpsc::channel(4);
        let slots = Mutex::new(HashMap::new());

        // First CB_SEQUENCE on slot 0 with seqid 1 → accepted.
        let mut frame = cb_sequence_frame(0xAABBCCDD, &session_id, 0, 1);
        let reply = handle_cb_compound(&mut frame, &session_id, &tx, &slots).unwrap();
        // Reply layout: xid + msg_type(=1) + reply_stat + verf(flavor+len) + accept_stat
        //   + compound{status + tag_len + resarray_len} + op{opcode + op_status + ...}
        assert_eq!(u32_at(&reply, 0), 0xAABBCCDD); // xid echoed
        assert_eq!(u32_at(&reply, 4), 1); // msg_type = REPLY
        assert_eq!(u32_at(&reply, 24), 0); // CB_COMPOUND status = NFS4_OK
        assert_eq!(u32_at(&reply, 32), 1); // resarray len = 1
        assert_eq!(u32_at(&reply, 36), 11); // opcode = CB_SEQUENCE
        assert_eq!(u32_at(&reply, 40), 0); // op status = NFS4_OK

        // Replaying seqid 1 on slot 0 is misordered (expected is now 2).
        let mut frame2 = cb_sequence_frame(0xAABBCCDE, &session_id, 0, 1);
        let reply2 = handle_cb_compound(&mut frame2, &session_id, &tx, &slots).unwrap();
        assert_eq!(u32_at(&reply2, 36), 11); // opcode
        assert_eq!(u32_at(&reply2, 40), NFS4ERR_SEQ_MISORDERED); // misordered
    }

    #[test]
    fn cb_sequence_session_mismatch_dropped() {
        let session_id = [1u8; 16];
        let wrong = [2u8; 16];
        let (tx, _rx) = mpsc::channel(4);
        let slots = Mutex::new(HashMap::new());
        // Frame carries `wrong` session id; handler must reject → None.
        let mut frame = cb_sequence_frame(1, &wrong, 0, 1);
        assert!(handle_cb_compound(&mut frame, &session_id, &tx, &slots).is_none());
    }

    #[test]
    fn cb_recall_forwards_notification() {
        let session_id = [9u8; 16];
        let stateid = [0xEE; 16];
        let fh = b"file-handle-xyz"; // 15 bytes → exercises XDR padding
        let (tx, mut rx) = mpsc::channel(4);
        let slots = Mutex::new(HashMap::new());

        let mut frame = cb_recall_frame(0x11223344, &stateid, true, fh);
        let reply = handle_cb_compound(&mut frame, &session_id, &tx, &slots).unwrap();
        assert_eq!(u32_at(&reply, 36), 4); // opcode = CB_RECALL
        assert_eq!(u32_at(&reply, 40), 0); // NFS4_OK

        match rx.try_recv().expect("recall notification forwarded") {
            RecallNotification::Delegation {
                stateid: sid,
                truncate,
                fh: nfh,
            } => {
                assert_eq!(sid, stateid);
                assert!(truncate);
                assert_eq!(&nfh[..], &fh[..]);
            }
            other => panic!("expected Delegation, got {other:?}"),
        }
    }

    /// Build a CB_COMPOUND RPC CALL carrying a single CB_LAYOUTRECALL op.
    /// `file_body`: Some((fh, offset, length, stateid)) for FILE; None for FSID/ALL.
    fn cb_layoutrecall_frame(
        xid: u32,
        iomode: u32,
        recalltype: u32,
        file_body: Option<(&[u8], u64, u64, &[u8; 16])>,
    ) -> Bytes {
        let mut f = Vec::new();
        f.extend_from_slice(&be(xid));
        f.extend_from_slice(&be(0)); // CALL
        f.extend_from_slice(&be(2));
        f.extend_from_slice(&be(CB_PROGRAM));
        f.extend_from_slice(&be(1));
        f.extend_from_slice(&be(1)); // CB_COMPOUND
        f.extend_from_slice(&be(0)); // cred
        f.extend_from_slice(&be(0));
        f.extend_from_slice(&be(0)); // verf
        f.extend_from_slice(&be(0));
        f.extend_from_slice(&be(0)); // tag
        f.extend_from_slice(&be(1)); // minorversion
        f.extend_from_slice(&be(0)); // callback_ident
        f.extend_from_slice(&be(1)); // num_ops
        f.extend_from_slice(&be(5)); // opcode = CB_LAYOUTRECALL
        f.extend_from_slice(&be(1)); // clora_type = LAYOUT4_NFSV4_1_FILES
        f.extend_from_slice(&be(iomode)); // clora_iomode
        f.extend_from_slice(&be(0)); // clora_changed = false
        f.extend_from_slice(&be(recalltype));
        match recalltype {
            1 => {
                let (fh, offset, length, stateid) = file_body.expect("FILE recall needs body");
                f.extend_from_slice(&be(fh.len() as u32));
                f.extend_from_slice(fh);
                let pad = (4 - fh.len() % 4) % 4;
                f.extend_from_slice(&[0u8; 4][..pad]);
                f.extend_from_slice(&offset.to_be_bytes());
                f.extend_from_slice(&length.to_be_bytes());
                f.extend_from_slice(stateid);
            }
            2 => {
                f.extend_from_slice(&[0u8; 16]); // fsid4: major(8) + minor(8)
            }
            _ => {}
        }
        Bytes::from(f)
    }

    #[test]
    fn cb_layoutrecall_file_forwards_notification() {
        let session_id = [3u8; 16];
        let stateid = [0xAB; 16];
        let fh = b"layout-fh-123"; // 13 bytes → exercises XDR padding
        let (tx, mut rx) = mpsc::channel(4);
        let slots = Mutex::new(HashMap::new());

        let mut frame = cb_layoutrecall_frame(
            0x55667788,
            2, // LAYOUTIOMODE4_RW
            1, // LAYOUTRECALL4_FILE
            Some((fh, 0, u64::MAX, &stateid)),
        );
        let reply = handle_cb_compound(&mut frame, &session_id, &tx, &slots).unwrap();
        assert_eq!(u32_at(&reply, 36), 5); // opcode = CB_LAYOUTRECALL
        assert_eq!(u32_at(&reply, 40), 0); // NFS4_OK

        match rx.try_recv().expect("layout recall forwarded") {
            RecallNotification::LayoutFile {
                stateid: sid,
                fh: nfh,
                offset,
                length,
                iomode,
            } => {
                assert_eq!(sid, stateid);
                assert_eq!(&nfh[..], &fh[..]);
                assert_eq!(offset, 0);
                assert_eq!(length, u64::MAX);
                assert_eq!(iomode, 2);
            }
            other => panic!("expected LayoutFile, got {other:?}"),
        }
    }

    #[test]
    fn cb_layoutrecall_all_forwards_notification() {
        let session_id = [3u8; 16];
        let (tx, mut rx) = mpsc::channel(4);
        let slots = Mutex::new(HashMap::new());

        for recalltype in [2u32, 3u32] {
            let mut frame = cb_layoutrecall_frame(1, 3, recalltype, None);
            let reply = handle_cb_compound(&mut frame, &session_id, &tx, &slots).unwrap();
            assert_eq!(u32_at(&reply, 40), 0); // NFS4_OK
            match rx.try_recv().expect("layout recall forwarded") {
                RecallNotification::LayoutAll => {}
                other => panic!("expected LayoutAll, got {other:?}"),
            }
        }
    }

    #[test]
    fn cb_layoutrecall_truncated_no_panic() {
        let session_id = [3u8; 16];
        let (tx, mut rx) = mpsc::channel(4);
        let slots = Mutex::new(HashMap::new());

        // FILE recall 但 fh 之后的 offset/length/stateid 被截断
        let full = cb_layoutrecall_frame(1, 2, 1, Some((b"fh", 7, 9, &[1u8; 16])));
        let mut truncated = full.slice(..full.len() - 20);
        // 截断帧按错误路径丢弃(None),不 panic,也不发通知
        assert!(handle_cb_compound(&mut truncated, &session_id, &tx, &slots).is_none());
        assert!(rx.try_recv().is_err());
    }
}