Skip to main content

air_sys_syscall/
fs.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Wrappers de la famille `fs` — filesystem, I/O synchrone, métadonnées.
6//!
7//! Cf. `docs/specs/layer-0/family-fs.md`.
8
9#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
10compile_error!("air-sys-syscall::fs supporte uniquement x86_64 et aarch64 (ADR-014).");
11
12use air_sys_types::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd, RawFd};
13use alloc::vec::Vec;
14use core::ffi::CStr;
15use core::num::NonZeroI32;
16
17use air_sys_types::Errno;
18use air_sys_types::fs::{
19    AccessFlags, AccessMode, DirEntry, DirEntryType, DirFd, FallocateMode, FdFlags, FileHandle,
20    FileLock, FsType, Mode, NameToHandleFlags, OpenFlags, OpenHow, RenameFlags, Seals, SeekWhence,
21    StatFsResult, StatusFlags, StatxFlags, StatxMask, StatxResult, StatxTimestamp, UtimeValue,
22};
23use air_sys_types::net::{IoSlice, IoSliceMut};
24
25// ─────────────────────────────────────────────────────────────────────────
26// Constantes kernel
27// ─────────────────────────────────────────────────────────────────────────
28
29const AT_FDCWD: i32 = -100;
30const O_CLOEXEC: u64 = 0o2_000_000;
31
32/// Surveillance de fichiers (inotify). Sous-module dédié `fs::inotify`
33/// (cf. `docs/specs/layer-0/family-fs-inotify.md`).
34pub mod inotify;
35
36// fcntl commands
37const F_DUPFD_CLOEXEC: i32 = 1030;
38const F_GETFD: i32 = 1;
39const F_SETFD: i32 = 2;
40const F_GETFL: i32 = 3;
41const F_SETFL: i32 = 4;
42const F_GETPIPE_SZ: i32 = 1032;
43const F_SETPIPE_SZ: i32 = 1031;
44const F_SETLK: i32 = 6;
45const F_SETLKW: i32 = 7;
46const F_ADD_SEALS: i32 = 1033;
47const F_GET_SEALS: i32 = 1034;
48
49// utimensat special values
50const UTIME_NOW: i64 = 0x3FFF_FFFF;
51const UTIME_OMIT: i64 = 0x3FFF_FFFE;
52
53// ─────────────────────────────────────────────────────────────────────────
54// Helpers internes
55// ─────────────────────────────────────────────────────────────────────────
56
57fn dirfd_to_raw(dirfd: DirFd<'_>) -> i32 {
58    match dirfd {
59        DirFd::Cwd => AT_FDCWD,
60        DirFd::Fd(fd) => fd.as_raw_fd(),
61    }
62}
63
64/// Convertit un fd (i32, potentiellement AT_FDCWD = -100) en u64 pour le syscall ABI.
65///
66/// Un fd négatif (AT_FDCWD = -100) est une sentinelle valide du kernel ; la
67/// représentation u64 en complément à deux correspond exactement à ce que le kernel attend.
68#[inline]
69fn fd_to_u64(fd: i32) -> u64 {
70    // SAFETY: les syscalls sur x86_64/aarch64 prennent les arguments dans des
71    // registres 64 bits. Un fd négatif (ex. AT_FDCWD = -100) est transmis en
72    // extension de signe vers 64 bits, ce que le cast i32 → i64 → u64 produit.
73    #[allow(clippy::cast_sign_loss)]
74    {
75        fd as u64
76    }
77}
78
79/// Convertit un i32 en u64 (extension de signe) pour les arguments syscall.
80#[inline]
81fn i32_to_u64(v: i32) -> u64 {
82    // Les valeurs i32 négatives (ex. O_* flags, AT_REMOVEDIR, whence) sont des
83    // arguments syscall valides ; leur représentation u64 en complément à deux
84    // est correcte pour l'ABI kernel.
85    #[allow(clippy::cast_sign_loss)]
86    {
87        v as u64
88    }
89}
90
91/// Convertit un i64 retourné par un syscall en usize.
92///
93/// Précondition : `ret >= 0` (déjà vérifié avant l'appel).
94#[inline]
95fn ret_to_usize(ret: i64) -> usize {
96    // ret >= 0 et <= SSIZE_MAX sur toutes les cibles 64 bits Air (ADR-014) ;
97    // la troncature en usize est exacte sur les cibles 64 bits.
98    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
99    {
100        ret as usize
101    }
102}
103
104/// Convertit un i64 en u64 (interprétation bit-à-bit) pour les arguments syscall.
105///
106/// Utilisé pour les offsets signés passés au kernel (ex. lseek, ftruncate, fallocate).
107/// L'ABI syscall Linux attend les valeurs dans des registres 64 bits ; un offset
108/// négatif est valide et représenté en complément à deux.
109#[inline]
110fn i64_to_u64(v: i64) -> u64 {
111    #[allow(clippy::cast_sign_loss)]
112    {
113        v as u64
114    }
115}
116
117/// Wraps `ret_to_usize` dans un `Ok()` — utilisé comme dernière expression des fonctions
118/// de type I/O retournant un compte d'octets.
119#[inline]
120fn ret_to_usize_ok(ret: i64) -> Result<usize, Errno> {
121    Ok(ret_to_usize(ret))
122}
123fn errno_from_negative_syscall_ret(ret: i64) -> Errno {
124    debug_assert!(ret < 0 && ret > -4096);
125    // `ret.wrapping_neg()` est donc dans `[1, 4095]` — toujours fit in i32.
126    #[allow(clippy::cast_possible_truncation)]
127    let raw = ret.wrapping_neg() as i32;
128    let nz = NonZeroI32::new(raw).expect("errno strictement positif par construction");
129    Errno::from_nonzero(nz)
130}
131
132// ─────────────────────────────────────────────────────────────────────────
133// Structs kernel privées
134// ─────────────────────────────────────────────────────────────────────────
135
136#[repr(C)]
137struct KernelOpenHow {
138    flags: u64,
139    mode: u64,
140    resolve: u64,
141}
142
143const _: () = assert!(core::mem::size_of::<KernelOpenHow>() == 24);
144
145#[repr(C)]
146struct KernelStatxTimestamp {
147    tv_sec: i64,
148    tv_nsec: u32,
149    __reserved: i32,
150}
151
152#[repr(C)]
153struct KernelStatx {
154    stx_mask: u32,
155    stx_blksize: u32,
156    stx_attributes: u64,
157    stx_nlink: u32,
158    stx_uid: u32,
159    stx_gid: u32,
160    stx_mode: u16,
161    __spare0: [u16; 1],
162    stx_ino: u64,
163    stx_size: u64,
164    stx_blocks: u64,
165    stx_attributes_mask: u64,
166    stx_atime: KernelStatxTimestamp,
167    stx_btime: KernelStatxTimestamp,
168    stx_ctime: KernelStatxTimestamp,
169    stx_mtime: KernelStatxTimestamp,
170    stx_rdev_major: u32,
171    stx_rdev_minor: u32,
172    stx_dev_major: u32,
173    stx_dev_minor: u32,
174    stx_mnt_id: u64,
175    __spare2: u64,
176    __spare3: [u64; 12],
177}
178
179const _: () = assert!(core::mem::size_of::<KernelStatx>() == 256);
180
181#[repr(C)]
182struct KernelStatfs {
183    f_type: i64,
184    f_bsize: i64,
185    f_blocks: u64,
186    f_bfree: u64,
187    f_bavail: u64,
188    f_files: u64,
189    f_ffree: u64,
190    f_fsid: [u32; 2],
191    f_namelen: i64,
192    f_frsize: i64,
193    f_flags: i64,
194    f_spare: [i64; 4],
195}
196
197const _: () = assert!(core::mem::size_of::<KernelStatfs>() == 120);
198
199#[repr(C)]
200struct KernelFlock {
201    l_type: i16,
202    l_whence: i16,
203    _pad: i32,
204    l_start: i64,
205    l_len: i64,
206    l_pid: i32,
207    _pad2: i32,
208}
209
210#[repr(C)]
211struct KernelFileHandle {
212    handle_bytes: u32,
213    handle_type: i32,
214    f_handle: [u8; 128],
215}
216
217// ─────────────────────────────────────────────────────────────────────────
218// Syscall raw helpers — x86_64
219// ─────────────────────────────────────────────────────────────────────────
220
221#[cfg(target_arch = "x86_64")]
222#[inline]
223unsafe fn syscall1(nr: i64, a0: u64) -> i64 {
224    let ret: i64;
225    // SAFETY: appelant garantit la validité des arguments.
226    unsafe {
227        core::arch::asm!(
228            "syscall",
229            in("rax") nr,
230            in("rdi") a0,
231            lateout("rax") ret,
232            lateout("rcx") _,
233            lateout("r11") _,
234            options(nostack, preserves_flags),
235        );
236    }
237    ret
238}
239
240#[cfg(target_arch = "x86_64")]
241#[inline]
242unsafe fn syscall2(nr: i64, a0: u64, a1: u64) -> i64 {
243    let ret: i64;
244    // SAFETY: appelant garantit la validité des arguments.
245    unsafe {
246        core::arch::asm!(
247            "syscall",
248            in("rax") nr,
249            in("rdi") a0,
250            in("rsi") a1,
251            lateout("rax") ret,
252            lateout("rcx") _,
253            lateout("r11") _,
254            options(nostack, preserves_flags),
255        );
256    }
257    ret
258}
259
260#[cfg(target_arch = "x86_64")]
261#[inline]
262unsafe fn syscall3(nr: i64, a0: u64, a1: u64, a2: u64) -> i64 {
263    let ret: i64;
264    // SAFETY: appelant garantit la validité des arguments.
265    unsafe {
266        core::arch::asm!(
267            "syscall",
268            in("rax") nr,
269            in("rdi") a0,
270            in("rsi") a1,
271            in("rdx") a2,
272            lateout("rax") ret,
273            lateout("rcx") _,
274            lateout("r11") _,
275            options(nostack, preserves_flags),
276        );
277    }
278    ret
279}
280
281#[cfg(target_arch = "x86_64")]
282#[inline]
283unsafe fn syscall4(nr: i64, a0: u64, a1: u64, a2: u64, a3: u64) -> i64 {
284    let ret: i64;
285    // SAFETY: appelant garantit la validité des arguments.
286    unsafe {
287        core::arch::asm!(
288            "syscall",
289            in("rax") nr,
290            in("rdi") a0,
291            in("rsi") a1,
292            in("rdx") a2,
293            in("r10") a3,
294            lateout("rax") ret,
295            lateout("rcx") _,
296            lateout("r11") _,
297            options(nostack, preserves_flags),
298        );
299    }
300    ret
301}
302
303#[cfg(target_arch = "x86_64")]
304#[inline]
305unsafe fn syscall5(nr: i64, a0: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> i64 {
306    let ret: i64;
307    // SAFETY: appelant garantit la validité des arguments.
308    unsafe {
309        core::arch::asm!(
310            "syscall",
311            in("rax") nr,
312            in("rdi") a0,
313            in("rsi") a1,
314            in("rdx") a2,
315            in("r10") a3,
316            in("r8") a4,
317            lateout("rax") ret,
318            lateout("rcx") _,
319            lateout("r11") _,
320            options(nostack, preserves_flags),
321        );
322    }
323    ret
324}
325
326#[cfg(target_arch = "x86_64")]
327#[inline]
328unsafe fn syscall6(nr: i64, a0: u64, a1: u64, a2: u64, a3: u64, a4: u64, a5: u64) -> i64 {
329    let ret: i64;
330    // SAFETY: appelant garantit la validité des arguments.
331    unsafe {
332        core::arch::asm!(
333            "syscall",
334            in("rax") nr,
335            in("rdi") a0,
336            in("rsi") a1,
337            in("rdx") a2,
338            in("r10") a3,
339            in("r8") a4,
340            in("r9") a5,
341            lateout("rax") ret,
342            lateout("rcx") _,
343            lateout("r11") _,
344            options(nostack, preserves_flags),
345        );
346    }
347    ret
348}
349
350// ─────────────────────────────────────────────────────────────────────────
351// Syscall raw helpers — aarch64
352// ─────────────────────────────────────────────────────────────────────────
353
354#[cfg(target_arch = "aarch64")]
355#[inline]
356unsafe fn syscall1(nr: i64, a0: u64) -> i64 {
357    let ret: i64;
358    // SAFETY: appelant garantit la validité des arguments.
359    unsafe {
360        core::arch::asm!(
361            "svc 0",
362            in("x8") nr,
363            inout("x0") a0 => ret,
364            options(nostack, preserves_flags),
365        );
366    }
367    ret
368}
369
370#[cfg(target_arch = "aarch64")]
371#[inline]
372unsafe fn syscall2(nr: i64, a0: u64, a1: u64) -> i64 {
373    let ret: i64;
374    // SAFETY: appelant garantit la validité des arguments.
375    unsafe {
376        core::arch::asm!(
377            "svc 0",
378            in("x8") nr,
379            inout("x0") a0 => ret,
380            in("x1") a1,
381            options(nostack, preserves_flags),
382        );
383    }
384    ret
385}
386
387#[cfg(target_arch = "aarch64")]
388#[inline]
389unsafe fn syscall3(nr: i64, a0: u64, a1: u64, a2: u64) -> i64 {
390    let ret: i64;
391    // SAFETY: appelant garantit la validité des arguments.
392    unsafe {
393        core::arch::asm!(
394            "svc 0",
395            in("x8") nr,
396            inout("x0") a0 => ret,
397            in("x1") a1,
398            in("x2") a2,
399            options(nostack, preserves_flags),
400        );
401    }
402    ret
403}
404
405#[cfg(target_arch = "aarch64")]
406#[inline]
407unsafe fn syscall4(nr: i64, a0: u64, a1: u64, a2: u64, a3: u64) -> i64 {
408    let ret: i64;
409    // SAFETY: appelant garantit la validité des arguments.
410    unsafe {
411        core::arch::asm!(
412            "svc 0",
413            in("x8") nr,
414            inout("x0") a0 => ret,
415            in("x1") a1,
416            in("x2") a2,
417            in("x3") a3,
418            options(nostack, preserves_flags),
419        );
420    }
421    ret
422}
423
424#[cfg(target_arch = "aarch64")]
425#[inline]
426unsafe fn syscall5(nr: i64, a0: u64, a1: u64, a2: u64, a3: u64, a4: u64) -> i64 {
427    let ret: i64;
428    // SAFETY: appelant garantit la validité des arguments.
429    unsafe {
430        core::arch::asm!(
431            "svc 0",
432            in("x8") nr,
433            inout("x0") a0 => ret,
434            in("x1") a1,
435            in("x2") a2,
436            in("x3") a3,
437            in("x4") a4,
438            options(nostack, preserves_flags),
439        );
440    }
441    ret
442}
443
444#[cfg(target_arch = "aarch64")]
445#[inline]
446unsafe fn syscall6(nr: i64, a0: u64, a1: u64, a2: u64, a3: u64, a4: u64, a5: u64) -> i64 {
447    let ret: i64;
448    // SAFETY: appelant garantit la validité des arguments.
449    unsafe {
450        core::arch::asm!(
451            "svc 0",
452            in("x8") nr,
453            inout("x0") a0 => ret,
454            in("x1") a1,
455            in("x2") a2,
456            in("x3") a3,
457            in("x4") a4,
458            in("x5") a5,
459            options(nostack, preserves_flags),
460        );
461    }
462    ret
463}
464
465// ─────────────────────────────────────────────────────────────────────────
466// Numéros de syscall par architecture
467// ─────────────────────────────────────────────────────────────────────────
468
469#[cfg(target_arch = "x86_64")]
470mod nr {
471    pub const READ: i64 = 0;
472    pub const WRITE: i64 = 1;
473    pub const CLOSE: i64 = 3;
474    pub const LSEEK: i64 = 8;
475    pub const PREAD64: i64 = 17;
476    pub const PWRITE64: i64 = 18;
477    pub const READV: i64 = 19;
478    pub const WRITEV: i64 = 20;
479    pub const PREADV: i64 = 295;
480    pub const PWRITEV: i64 = 296;
481    pub const FCNTL: i64 = 72;
482    pub const FLOCK: i64 = 73;
483    pub const FSYNC: i64 = 74;
484    pub const FDATASYNC: i64 = 75;
485    pub const FCHMOD: i64 = 91;
486    pub const FCHOWN: i64 = 93;
487    pub const SENDFILE: i64 = 40;
488    pub const FTRUNCATE: i64 = 77;
489    pub const GETDENTS64: i64 = 217;
490    pub const STATFS: i64 = 137;
491    pub const FSTATFS: i64 = 138;
492    pub const FALLOCATE: i64 = 285;
493    pub const OPENAT: i64 = 257;
494    pub const MKDIRAT: i64 = 258;
495    pub const MKNODAT: i64 = 259;
496    pub const FCHOWNAT: i64 = 260;
497    pub const UNLINKAT: i64 = 263;
498    pub const LINKAT: i64 = 265;
499    pub const SYMLINKAT: i64 = 266;
500    pub const READLINKAT: i64 = 267;
501    pub const FCHMODAT: i64 = 268;
502    pub const FACCESSAT: i64 = 269;
503    pub const FACCESSAT2: i64 = 439;
504    pub const UTIMENSAT: i64 = 280;
505    pub const COPY_FILE_RANGE: i64 = 326;
506    pub const STATX: i64 = 332;
507    pub const NAME_TO_HANDLE_AT: i64 = 303;
508    pub const OPEN_BY_HANDLE_AT: i64 = 304;
509    pub const SYNC_FILE_RANGE: i64 = 277;
510    pub const RENAMEAT2: i64 = 316;
511    pub const OPENAT2: i64 = 437;
512    pub const INOTIFY_INIT1: i64 = 294;
513    pub const INOTIFY_ADD_WATCH: i64 = 254;
514    pub const INOTIFY_RM_WATCH: i64 = 255;
515}
516
517#[cfg(target_arch = "aarch64")]
518mod nr {
519    pub const READ: i64 = 63;
520    pub const WRITE: i64 = 64;
521    pub const CLOSE: i64 = 57;
522    pub const LSEEK: i64 = 62;
523    pub const PREAD64: i64 = 67;
524    pub const PWRITE64: i64 = 68;
525    pub const READV: i64 = 65;
526    pub const WRITEV: i64 = 66;
527    pub const PREADV: i64 = 69;
528    pub const PWRITEV: i64 = 70;
529    pub const FCNTL: i64 = 25;
530    pub const FLOCK: i64 = 32;
531    pub const FSYNC: i64 = 82;
532    pub const FDATASYNC: i64 = 83;
533    pub const FCHMOD: i64 = 52;
534    pub const FCHOWN: i64 = 55;
535    pub const SENDFILE: i64 = 71;
536    pub const FTRUNCATE: i64 = 46;
537    pub const GETDENTS64: i64 = 61;
538    pub const STATFS: i64 = 43;
539    pub const FSTATFS: i64 = 44;
540    pub const FALLOCATE: i64 = 47;
541    pub const OPENAT: i64 = 56;
542    pub const MKDIRAT: i64 = 34;
543    pub const MKNODAT: i64 = 33;
544    pub const FCHOWNAT: i64 = 54;
545    pub const UNLINKAT: i64 = 35;
546    pub const LINKAT: i64 = 37;
547    pub const SYMLINKAT: i64 = 36;
548    pub const READLINKAT: i64 = 78;
549    pub const FCHMODAT: i64 = 53;
550    pub const FACCESSAT: i64 = 48;
551    pub const FACCESSAT2: i64 = 439;
552    pub const UTIMENSAT: i64 = 88;
553    pub const COPY_FILE_RANGE: i64 = 285;
554    pub const STATX: i64 = 291;
555    pub const NAME_TO_HANDLE_AT: i64 = 264;
556    pub const OPEN_BY_HANDLE_AT: i64 = 265;
557    pub const SYNC_FILE_RANGE: i64 = 84;
558    pub const RENAMEAT2: i64 = 276;
559    pub const OPENAT2: i64 = 437;
560    pub const INOTIFY_INIT1: i64 = 26;
561    pub const INOTIFY_ADD_WATCH: i64 = 27;
562    pub const INOTIFY_RM_WATCH: i64 = 28;
563}
564
565// ─────────────────────────────────────────────────────────────────────────
566// Ouverture et fermeture
567// ─────────────────────────────────────────────────────────────────────────
568
569/// Ouvre un fichier avec contrôle de résolution de chemin avancé.
570///
571/// Wrappeur de `openat2(2)` (Linux 5.6+, numéro 437). Préféré à
572/// [`openat`] car il permet de contraindre la résolution de chemin via
573/// [`OpenHow::resolve`]. Retombe automatiquement sur [`openat`] si le
574/// kernel retourne `ENOSYS` (< Linux 5.6).
575///
576/// `O_CLOEXEC` est **toujours** ajouté aux flags par le wrapper —
577/// tous les FDs ouverts par Air ont `CLOEXEC` par défaut.
578///
579/// # Parameters
580///
581/// - `dirfd` : répertoire de base pour la résolution ([`DirFd::Cwd`] ou
582///   un FD de répertoire).
583/// - `path` : chemin relatif à `dirfd` (ou absolu si `dirfd` est ignoré).
584/// - `how` : flags, mode et politique de résolution.
585///
586/// # Errors
587///
588/// - `EACCES` : permissions insuffisantes.
589/// - `ENOENT` : chemin inexistant (sans `CREAT`).
590/// - `EEXIST` : fichier existant avec `CREAT | EXCL`.
591/// - `EISDIR` : ouverture en écriture d'un répertoire.
592/// - `ENOTDIR` : un composant du chemin n'est pas un répertoire.
593/// - `EXDEV` : violation de `RESOLVE_NO_XDEV`.
594/// - `EAGAIN` : violation de `RESOLVE_BENEATH` ou `RESOLVE_NO_SYMLINKS`.
595/// - `ENFILE` / `EMFILE` : quotas FD atteints.
596///
597/// # Examples
598///
599/// ```no_run
600/// use air_sys_syscall::fs::openat2;
601/// use air_sys_types::fs::{DirFd, OpenHow, OpenFlags, ResolveFlags};
602///
603/// let how = OpenHow {
604///     flags: OpenFlags::RDONLY,
605///     mode: 0,
606///     resolve: ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS,
607/// };
608/// let fd = openat2(DirFd::Cwd, c"./config.toml", how).expect("openat2");
609/// ```
610pub fn openat2(dirfd: DirFd<'_>, path: &CStr, how: OpenHow) -> Result<OwnedFd, Errno> {
611    let raw_dirfd = dirfd_to_raw(dirfd);
612    let kernel_how = KernelOpenHow {
613        flags: how.flags.bits() | O_CLOEXEC,
614        mode: u64::from(how.mode),
615        resolve: how.resolve.bits(),
616    };
617    let path_ptr = path.as_ptr() as u64;
618    let how_ptr = &kernel_how as *const KernelOpenHow as u64;
619    let how_size = core::mem::size_of::<KernelOpenHow>();
620
621    // SAFETY:
622    // - path_ptr pointe sur un CStr nul-terminé valide pour la durée du syscall.
623    // - how_ptr pointe sur KernelOpenHow local valide, size_of correct.
624    // - raw_dirfd est AT_FDCWD ou un fd valide fourni par l'appelant.
625    let ret = unsafe {
626        syscall4(
627            nr::OPENAT2,
628            fd_to_u64(raw_dirfd),
629            path_ptr,
630            how_ptr,
631            how_size as u64,
632        )
633    };
634
635    if ret < 0 {
636        let err = errno_from_negative_syscall_ret(ret);
637        // Fallback sur openat si ENOSYS (kernel < 5.6)
638        if err == Errno::ENOSYS {
639            return openat(dirfd, path, how.flags, how.mode);
640        }
641        return Err(err);
642    }
643
644    // SAFETY: ret est un fd valide retourné par le kernel après succès.
645    #[allow(clippy::cast_possible_truncation)]
646    Ok(unsafe { OwnedFd::from_raw_fd(ret as i32) })
647}
648
649/// Ouvre un fichier (version sans contrôle de résolution avancé).
650///
651/// Wrappeur de `openat(2)`. Utilisé en fallback quand `openat2` n'est
652/// pas disponible (kernel < 5.6). `O_CLOEXEC` est toujours ajouté.
653///
654/// # Parameters
655///
656/// - `dirfd` : répertoire de base.
657/// - `path` : chemin relatif ou absolu.
658/// - `flags` : flags d'ouverture.
659/// - `mode` : permissions de création (significatif seulement avec `CREAT`).
660///
661/// # Errors
662///
663/// Voir [`openat2`] (sans les codes liés à `RESOLVE_*`).
664///
665/// # Examples
666///
667/// ```no_run
668/// use air_sys_syscall::fs::openat;
669/// use air_sys_types::fs::{DirFd, OpenFlags};
670///
671/// let fd = openat(DirFd::Cwd, c"./data.bin", OpenFlags::RDONLY, 0)
672///     .expect("openat");
673/// ```
674pub fn openat(
675    dirfd: DirFd<'_>,
676    path: &CStr,
677    flags: OpenFlags,
678    mode: Mode,
679) -> Result<OwnedFd, Errno> {
680    let raw_dirfd = dirfd_to_raw(dirfd);
681    let raw_flags = flags.bits() | O_CLOEXEC;
682    // raw_flags fits in i32 (O_* constants are all < 2^31).
683    #[allow(clippy::cast_possible_truncation)]
684    let raw_flags_i32 = raw_flags as i32;
685    let path_ptr = path.as_ptr() as u64;
686
687    // SAFETY:
688    // - path_ptr est un CStr nul-terminé valide pour la durée du syscall.
689    // - raw_dirfd est AT_FDCWD ou un fd valide.
690    // - raw_flags_i32 contient des bits O_* valides.
691    // - mode est une valeur Mode (u32) ; les 12 bits inférieurs sont utilisés.
692    let ret = unsafe {
693        syscall4(
694            nr::OPENAT,
695            fd_to_u64(raw_dirfd),
696            path_ptr,
697            i32_to_u64(raw_flags_i32),
698            u64::from(mode),
699        )
700    };
701
702    if ret < 0 {
703        return Err(errno_from_negative_syscall_ret(ret));
704    }
705
706    // SAFETY: ret est un fd valide retourné par le kernel après succès.
707    #[allow(clippy::cast_possible_truncation)]
708    Ok(unsafe { OwnedFd::from_raw_fd(ret as i32) })
709}
710
711/// Ferme un FD explicitement et récupère l'erreur éventuelle.
712///
713/// Consomme l'[`OwnedFd`]. Le `Drop` automatique ferme aussi le FD mais
714/// ignore les erreurs (ex. `EIO` sur NFS en cas de panne). Cette
715/// fonction permet de les détecter.
716///
717/// # Errors
718///
719/// - `EIO` : erreur d'E/S lors de la fermeture (ex. NFS).
720/// - `EINTR` : interrompu par un signal. Sur Linux, le FD est quand même
721///   fermé après `EINTR` (ne pas réessayer).
722///
723/// # Examples
724///
725/// ```no_run
726/// use air_sys_syscall::fs::{openat, close};
727/// use air_sys_types::fs::{DirFd, OpenFlags};
728///
729/// let fd = openat(DirFd::Cwd, c"./out.log", OpenFlags::WRONLY, 0)
730///     .expect("openat");
731/// close(fd).expect("close");
732/// ```
733pub fn close(fd: OwnedFd) -> Result<(), Errno> {
734    let raw = fd.as_raw_fd();
735    // Consomme le OwnedFd sans appeler son Drop (qui fermerait le fd une 2e fois).
736    core::mem::forget(fd);
737
738    // SAFETY:
739    // - raw est le fd de l'OwnedFd consommé, valide au moment de l'appel.
740    // - close(2) ne lit ni n'écrit de mémoire utilisateur.
741    let ret = unsafe { syscall1(nr::CLOSE, fd_to_u64(raw)) };
742
743    if ret < 0 {
744        return Err(errno_from_negative_syscall_ret(ret));
745    }
746    Ok(())
747}
748
749// ─────────────────────────────────────────────────────────────────────────
750// I/O synchrone
751// ─────────────────────────────────────────────────────────────────────────
752
753/// Lit depuis un FD dans `buffer`.
754///
755/// Wrappeur de `read(2)`. Peut retourner moins d'octets que `buffer.len()`
756/// (short read). L'appelant doit boucler si un read complet est requis.
757///
758/// # Errors
759///
760/// - `EAGAIN` : FD non-bloquant sans données disponibles.
761/// - `EINTR` : interrompu par un signal (retry à la charge de l'appelant).
762/// - `EIO` : erreur I/O bas niveau.
763/// - `EBADF` : FD invalide.
764///
765/// # Examples
766///
767/// ```no_run
768/// use air_sys_syscall::fs::read;
769/// use air_sys_types::fd::BorrowedFd;
770///
771/// # fn example(fd: BorrowedFd<'_>) {
772/// let mut buffer = [0u8; 1024];
773/// let n = read(fd, &mut buffer).expect("read");
774/// # }
775/// ```
776pub fn read(fd: BorrowedFd<'_>, buffer: &mut [u8]) -> Result<usize, Errno> {
777    // SAFETY:
778    // - buffer est un slice mutable valide pour la durée du syscall.
779    // - fd est un BorrowedFd valide.
780    // - read(2) écrit au plus buffer.len() octets dans buffer.
781    let ret = unsafe {
782        syscall3(
783            nr::READ,
784            fd_to_u64(fd.as_raw_fd()),
785            buffer.as_mut_ptr() as u64,
786            buffer.len() as u64,
787        )
788    };
789
790    if ret < 0 {
791        return Err(errno_from_negative_syscall_ret(ret));
792    }
793
794    // ret >= 0, au plus buffer.len() <= usize::MAX : cast sûr.
795    #[allow(clippy::cast_sign_loss)]
796    ret_to_usize_ok(ret)
797}
798
799/// Écrit `buffer` sur un FD.
800///
801/// Wrappeur de `write(2)`. Peut retourner moins d'octets écrits que
802/// `buffer.len()` (short write). L'appelant doit boucler si nécessaire.
803///
804/// # Errors
805///
806/// - `EAGAIN` : FD non-bloquant et buffer kernel plein.
807/// - `EINTR` : interrompu par un signal.
808/// - `EIO` : erreur I/O bas niveau.
809/// - `ENOSPC` : plus d'espace sur le device.
810/// - `EPIPE` : extrémité de lecture du pipe fermée.
811///
812/// # Examples
813///
814/// ```no_run
815/// use air_sys_syscall::fs::write;
816/// use air_sys_types::fd::BorrowedFd;
817///
818/// # fn example(fd: BorrowedFd<'_>) {
819/// let n = write(fd, b"hello\n").expect("write");
820/// # }
821/// ```
822pub fn write(fd: BorrowedFd<'_>, buffer: &[u8]) -> Result<usize, Errno> {
823    // SAFETY:
824    // - buffer est un slice immuable valide pour la durée du syscall.
825    // - write(2) lit au plus buffer.len() octets depuis buffer.
826    let ret = unsafe {
827        syscall3(
828            nr::WRITE,
829            fd_to_u64(fd.as_raw_fd()),
830            buffer.as_ptr() as u64,
831            buffer.len() as u64,
832        )
833    };
834
835    if ret < 0 {
836        return Err(errno_from_negative_syscall_ret(ret));
837    }
838
839    #[allow(clippy::cast_sign_loss)]
840    ret_to_usize_ok(ret)
841}
842
843/// Lit depuis une position absolue sans modifier la position courante du FD.
844///
845/// # Errors
846///
847/// Voir [`read`]. De plus : `ESPIPE` si le FD n'est pas seekable (pipe, socket).
848///
849/// # Examples
850///
851/// ```no_run
852/// use air_sys_syscall::fs::pread;
853/// use air_sys_types::fd::BorrowedFd;
854///
855/// # fn example(fd: BorrowedFd<'_>) {
856/// let mut buffer = [0u8; 512];
857/// let n = pread(fd, &mut buffer, 1024).expect("pread");
858/// # }
859/// ```
860pub fn pread(fd: BorrowedFd<'_>, buffer: &mut [u8], offset: u64) -> Result<usize, Errno> {
861    // SAFETY: buffer est un slice mutable valide ; pread64 écrit au plus buffer.len() octets.
862    let ret = unsafe {
863        syscall4(
864            nr::PREAD64,
865            fd_to_u64(fd.as_raw_fd()),
866            buffer.as_mut_ptr() as u64,
867            buffer.len() as u64,
868            offset,
869        )
870    };
871
872    if ret < 0 {
873        return Err(errno_from_negative_syscall_ret(ret));
874    }
875
876    #[allow(clippy::cast_sign_loss)]
877    ret_to_usize_ok(ret)
878}
879
880/// Écrit à une position absolue sans modifier la position courante du FD.
881///
882/// # Errors
883///
884/// Voir [`write()`]. De plus : `ESPIPE` si FD non seekable.
885///
886/// # Examples
887///
888/// ```no_run
889/// use air_sys_syscall::fs::pwrite;
890/// use air_sys_types::fd::BorrowedFd;
891///
892/// # fn example(fd: BorrowedFd<'_>) {
893/// let n = pwrite(fd, b"data", 0).expect("pwrite");
894/// # }
895/// ```
896pub fn pwrite(fd: BorrowedFd<'_>, buffer: &[u8], offset: u64) -> Result<usize, Errno> {
897    // SAFETY: buffer est un slice immuable valide ; pwrite64 lit au plus buffer.len() octets.
898    let ret = unsafe {
899        syscall4(
900            nr::PWRITE64,
901            fd_to_u64(fd.as_raw_fd()),
902            buffer.as_ptr() as u64,
903            buffer.len() as u64,
904            offset,
905        )
906    };
907
908    if ret < 0 {
909        return Err(errno_from_negative_syscall_ret(ret));
910    }
911
912    #[allow(clippy::cast_sign_loss)]
913    ret_to_usize_ok(ret)
914}
915
916/// Lecture scatter vers plusieurs buffers (`readv(2)`).
917///
918/// # Errors
919///
920/// Voir [`read`].
921///
922/// # Examples
923///
924/// ```no_run
925/// use air_sys_syscall::fs::readv;
926/// use air_sys_types::net::IoSliceMut;
927/// use air_sys_types::fd::BorrowedFd;
928///
929/// # fn example(fd: BorrowedFd<'_>) {
930/// let mut a = [0u8; 64];
931/// let mut b = [0u8; 64];
932/// let mut iov = [IoSliceMut::new(&mut a), IoSliceMut::new(&mut b)];
933/// let n = readv(fd, &mut iov).expect("readv");
934/// # }
935/// ```
936pub fn readv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>]) -> Result<usize, Errno> {
937    // SAFETY:
938    // - iov est un slice de IoSliceMut (repr(C), layout iovec) valide.
939    // - readv(2) écrit dans les buffers pointés par chaque iovec.
940    let ret = unsafe {
941        syscall3(
942            nr::READV,
943            fd_to_u64(fd.as_raw_fd()),
944            iov.as_ptr() as u64,
945            iov.len() as u64,
946        )
947    };
948
949    if ret < 0 {
950        return Err(errno_from_negative_syscall_ret(ret));
951    }
952
953    #[allow(clippy::cast_sign_loss)]
954    ret_to_usize_ok(ret)
955}
956
957/// Écriture gather depuis plusieurs buffers (`writev(2)`).
958///
959/// # Errors
960///
961/// Voir [`write()`].
962///
963/// # Examples
964///
965/// ```no_run
966/// use air_sys_syscall::fs::writev;
967/// use air_sys_types::net::IoSlice;
968/// use air_sys_types::fd::BorrowedFd;
969///
970/// # fn example(fd: BorrowedFd<'_>) {
971/// let a = b"header";
972/// let b = b"payload";
973/// let iov = [IoSlice::new(a), IoSlice::new(b)];
974/// let n = writev(fd, &iov).expect("writev");
975/// # }
976/// ```
977pub fn writev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>]) -> Result<usize, Errno> {
978    // SAFETY:
979    // - iov est un slice de IoSlice (repr(C), layout iovec) valide.
980    // - writev(2) lit dans les buffers pointés par chaque iovec.
981    let ret = unsafe {
982        syscall3(
983            nr::WRITEV,
984            fd_to_u64(fd.as_raw_fd()),
985            iov.as_ptr() as u64,
986            iov.len() as u64,
987        )
988    };
989
990    if ret < 0 {
991        return Err(errno_from_negative_syscall_ret(ret));
992    }
993
994    #[allow(clippy::cast_sign_loss)]
995    ret_to_usize_ok(ret)
996}
997
998/// Lecture scatter positionnée (`preadv(2)`).
999pub fn preadv(fd: BorrowedFd<'_>, iov: &mut [IoSliceMut<'_>], offset: u64) -> Result<usize, Errno> {
1000    // preadv ABI: (fd, iov, iovcnt, offset_lo, offset_hi) on x86_64
1001    // On aarch64 : (fd, iov, iovcnt, offset) — offset is a single 64-bit register.
1002    // We pass offset as a5 (r8/x4) with a4=0 on x86_64 for simplicity using 5-arg form.
1003    // Actually the Linux preadv ABI on x86_64 takes offset split into two 32-bit args
1004    // in r8 (low) and r9 (high). We use syscall6 to pass both halves.
1005    let off_lo = offset & 0xFFFF_FFFF;
1006    let off_hi = offset >> 32;
1007
1008    // SAFETY:
1009    // - iov est un slice de IoSliceMut valide.
1010    // - preadv écrit dans les buffers référencés par iov.
1011    // - offset est une position valide (non vérifiée par le wrapper, ESPIPE si non seekable).
1012    let ret = unsafe {
1013        syscall6(
1014            nr::PREADV,
1015            fd_to_u64(fd.as_raw_fd()),
1016            iov.as_ptr() as u64,
1017            iov.len() as u64,
1018            off_lo,
1019            off_hi,
1020            0,
1021        )
1022    };
1023
1024    if ret < 0 {
1025        return Err(errno_from_negative_syscall_ret(ret));
1026    }
1027
1028    #[allow(clippy::cast_sign_loss)]
1029    ret_to_usize_ok(ret)
1030}
1031
1032/// Écriture gather positionnée (`pwritev(2)`).
1033pub fn pwritev(fd: BorrowedFd<'_>, iov: &[IoSlice<'_>], offset: u64) -> Result<usize, Errno> {
1034    let off_lo = offset & 0xFFFF_FFFF;
1035    let off_hi = offset >> 32;
1036
1037    // SAFETY:
1038    // - iov est un slice de IoSlice valide.
1039    // - pwritev lit dans les buffers référencés par iov.
1040    let ret = unsafe {
1041        syscall6(
1042            nr::PWRITEV,
1043            fd_to_u64(fd.as_raw_fd()),
1044            iov.as_ptr() as u64,
1045            iov.len() as u64,
1046            off_lo,
1047            off_hi,
1048            0,
1049        )
1050    };
1051
1052    if ret < 0 {
1053        return Err(errno_from_negative_syscall_ret(ret));
1054    }
1055
1056    #[allow(clippy::cast_sign_loss)]
1057    ret_to_usize_ok(ret)
1058}
1059
1060// ─────────────────────────────────────────────────────────────────────────
1061// Positionnement
1062// ─────────────────────────────────────────────────────────────────────────
1063
1064/// Modifie la position courante d'un FD seekable.
1065///
1066/// Wrappeur de `lseek(2)`. Retourne la nouvelle position absolue.
1067///
1068/// # Parameters
1069///
1070/// - `fd` : FD seekable.
1071/// - `offset` : déplacement.
1072/// - `whence` : origine ([`SeekWhence::Set`], `Current`, `End`, `Data`, `Hole`).
1073///
1074/// # Errors
1075///
1076/// - `ESPIPE` : FD non seekable (pipe, socket).
1077/// - `EINVAL` : position résultante négative.
1078///
1079/// # Examples
1080///
1081/// ```no_run
1082/// use air_sys_syscall::fs::lseek;
1083/// use air_sys_types::fs::SeekWhence;
1084/// use air_sys_types::fd::BorrowedFd;
1085///
1086/// # fn example(fd: BorrowedFd<'_>) {
1087/// let pos = lseek(fd, 0, SeekWhence::End).expect("lseek");
1088/// # }
1089/// ```
1090pub fn lseek(fd: BorrowedFd<'_>, offset: i64, whence: SeekWhence) -> Result<u64, Errno> {
1091    // SAFETY: lseek(2) ne touche aucune mémoire utilisateur.
1092    let ret = unsafe {
1093        syscall3(
1094            nr::LSEEK,
1095            fd_to_u64(fd.as_raw_fd()),
1096            i64_to_u64(offset),
1097            i32_to_u64(whence as i32),
1098        )
1099    };
1100
1101    if ret < 0 {
1102        return Err(errno_from_negative_syscall_ret(ret));
1103    }
1104
1105    // La nouvelle position est non-négative.
1106    #[allow(clippy::cast_sign_loss)]
1107    #[allow(clippy::cast_sign_loss)]
1108    Ok(ret as u64)
1109}
1110
1111// ─────────────────────────────────────────────────────────────────────────
1112// Métadonnées
1113// ─────────────────────────────────────────────────────────────────────────
1114
1115/// Lit les métadonnées d'un fichier (API unifiée et moderne).
1116///
1117/// Wrappeur de `statx(2)` (Linux 4.11+, numéro x86_64: 332, aarch64: 291).
1118/// Remplace tous les anciens `stat`/`fstat`/`lstat`/`fstatat` — seul
1119/// wrapper de métadonnées exposé par Air.
1120///
1121/// Le paramètre `mask` sélectionne les champs souhaités ;
1122/// `StatxResult::mask` en sortie indique les champs effectivement
1123/// disponibles (sous-ensemble possible si le FS ne les supporte pas).
1124///
1125/// # Parameters
1126///
1127/// - `dirfd` : base de résolution.
1128/// - `path` : chemin (peut être vide avec `StatxFlags::EMPTY_PATH` pour
1129///   opérer directement sur le FD).
1130/// - `flags` : comportement de résolution.
1131/// - `mask` : champs à récupérer.
1132///
1133/// # Errors
1134///
1135/// - `ENOENT` : chemin inexistant.
1136/// - `EACCES` : permissions insuffisantes.
1137/// - `ENOSYS` : kernel < 4.11.
1138///
1139/// # Examples
1140///
1141/// ```no_run
1142/// use air_sys_syscall::fs::statx;
1143/// use air_sys_types::fs::{DirFd, StatxFlags, StatxMask};
1144///
1145/// let result = statx(
1146///     DirFd::Cwd,
1147///     c"./config.toml",
1148///     StatxFlags::empty(),
1149///     StatxMask::SIZE | StatxMask::MTIME,
1150/// ).expect("statx");
1151/// if result.mask.contains(StatxMask::SIZE) {
1152///     println!("size: {}", result.size);
1153/// }
1154/// ```
1155pub fn statx(
1156    dirfd: DirFd<'_>,
1157    path: &CStr,
1158    flags: StatxFlags,
1159    mask: StatxMask,
1160) -> Result<StatxResult, Errno> {
1161    let mut kstatx = KernelStatx {
1162        stx_mask: 0,
1163        stx_blksize: 0,
1164        stx_attributes: 0,
1165        stx_nlink: 0,
1166        stx_uid: 0,
1167        stx_gid: 0,
1168        stx_mode: 0,
1169        __spare0: [0],
1170        stx_ino: 0,
1171        stx_size: 0,
1172        stx_blocks: 0,
1173        stx_attributes_mask: 0,
1174        stx_atime: KernelStatxTimestamp {
1175            tv_sec: 0,
1176            tv_nsec: 0,
1177            __reserved: 0,
1178        },
1179        stx_btime: KernelStatxTimestamp {
1180            tv_sec: 0,
1181            tv_nsec: 0,
1182            __reserved: 0,
1183        },
1184        stx_ctime: KernelStatxTimestamp {
1185            tv_sec: 0,
1186            tv_nsec: 0,
1187            __reserved: 0,
1188        },
1189        stx_mtime: KernelStatxTimestamp {
1190            tv_sec: 0,
1191            tv_nsec: 0,
1192            __reserved: 0,
1193        },
1194        stx_rdev_major: 0,
1195        stx_rdev_minor: 0,
1196        stx_dev_major: 0,
1197        stx_dev_minor: 0,
1198        stx_mnt_id: 0,
1199        __spare2: 0,
1200        __spare3: [0u64; 12],
1201    };
1202
1203    let raw_dirfd = dirfd_to_raw(dirfd);
1204    let path_ptr = path.as_ptr() as u64;
1205    let buf_ptr = &mut kstatx as *mut KernelStatx as u64;
1206
1207    // SAFETY:
1208    // - path_ptr est un CStr nul-terminé valide.
1209    // - buf_ptr pointe sur KernelStatx local correctement dimensionné (256 octets).
1210    // - Le kernel remplit la structure si le syscall réussit.
1211    let ret = unsafe {
1212        syscall5(
1213            nr::STATX,
1214            fd_to_u64(raw_dirfd),
1215            path_ptr,
1216            u64::from(flags.bits()),
1217            u64::from(mask.bits()),
1218            buf_ptr,
1219        )
1220    };
1221
1222    if ret < 0 {
1223        return Err(errno_from_negative_syscall_ret(ret));
1224    }
1225
1226    let ts_from = |ts: &KernelStatxTimestamp| StatxTimestamp {
1227        seconds: ts.tv_sec,
1228        nanoseconds: ts.tv_nsec,
1229    };
1230
1231    Ok(StatxResult {
1232        mask: StatxMask::from_bits_truncate(kstatx.stx_mask),
1233        blksize: kstatx.stx_blksize,
1234        attributes: kstatx.stx_attributes,
1235        nlink: kstatx.stx_nlink,
1236        uid: kstatx.stx_uid,
1237        gid: kstatx.stx_gid,
1238        mode: kstatx.stx_mode,
1239        ino: kstatx.stx_ino,
1240        size: kstatx.stx_size,
1241        blocks: kstatx.stx_blocks,
1242        atime: ts_from(&kstatx.stx_atime),
1243        btime: ts_from(&kstatx.stx_btime),
1244        ctime: ts_from(&kstatx.stx_ctime),
1245        mtime: ts_from(&kstatx.stx_mtime),
1246        rdev_major: kstatx.stx_rdev_major,
1247        rdev_minor: kstatx.stx_rdev_minor,
1248        dev_major: kstatx.stx_dev_major,
1249        dev_minor: kstatx.stx_dev_minor,
1250        mount_id: kstatx.stx_mnt_id,
1251    })
1252}
1253
1254/// Teste les permissions d'accès à un fichier sans l'ouvrir.
1255///
1256/// Wrappeur de `faccessat(2)`. **Attention :** sujet à des races
1257/// TOCTOU — préférer ouvrir directement et gérer l'erreur.
1258///
1259/// # Routage par flags (faccessat vs faccessat2)
1260///
1261/// Le syscall noyau `faccessat` historique ne prend **que 3 arguments**
1262/// (`dirfd`, `path`, `mode`) : il **ignore silencieusement** tout flag
1263/// comportemental. Honorer `AT_SYMLINK_NOFOLLOW` / `AT_EACCESS` exige le
1264/// syscall `faccessat2` (numéro 439 sur x86_64 **et** aarch64, Linux 5.8+,
1265/// 4 arguments). Comme la glibc, ce wrapper route donc selon `flags` :
1266///
1267/// - `flags.is_empty()` → `faccessat` (3-arg, universellement disponible) ;
1268/// - `flags` non vide → `faccessat2` (4-arg, qui **applique** réellement les
1269///   flags).
1270///
1271/// La signature publique est inchangée ; seul le syscall sous-jacent diffère.
1272/// Sur un noyau < 5.8 sans `faccessat2`, un appel avec flags retourne `ENOSYS`
1273/// (l'appelant n'obtient jamais un résultat où le flag aurait été ignoré).
1274///
1275/// # Errors
1276///
1277/// - `EACCES` : accès refusé.
1278/// - `ENOENT` : chemin inexistant.
1279/// - `ENOSYS` : `faccessat2` indisponible (noyau < 5.8) et `flags` non vide.
1280///
1281/// # Examples
1282///
1283/// ```no_run
1284/// use air_sys_syscall::fs::faccessat;
1285/// use air_sys_types::fs::{AccessFlags, AccessMode, DirFd};
1286///
1287/// let result = faccessat(DirFd::Cwd, c"./script.sh", AccessMode::X_OK, AccessFlags::empty());
1288/// ```
1289pub fn faccessat(
1290    dirfd: DirFd<'_>,
1291    path: &CStr,
1292    mode: AccessMode,
1293    flags: AccessFlags,
1294) -> Result<(), Errno> {
1295    let raw_dirfd = dirfd_to_raw(dirfd);
1296
1297    let ret = if flags.is_empty() {
1298        // SAFETY: SYS_faccessat (x86_64 = 269) / SYS_faccessat (aarch64 = 48).
1299        // 3 arguments : (dirfd, path, mode). path est un CStr nul-terminé valide
1300        // pour la durée du syscall. Flags vides → le syscall 3-arg historique
1301        // suffit (il ne porte de toute façon aucun flag) et reste universellement
1302        // disponible.
1303        unsafe {
1304            syscall3(
1305                nr::FACCESSAT,
1306                fd_to_u64(raw_dirfd),
1307                path.as_ptr() as u64,
1308                u64::from(mode.bits()),
1309            )
1310        }
1311    } else {
1312        // SAFETY: SYS_faccessat2 (x86_64 = 439) / SYS_faccessat2 (aarch64 = 439).
1313        // 4 arguments : (dirfd, path, mode, flags). path est un CStr nul-terminé
1314        // valide pour la durée du syscall. C'est le seul syscall qui HONORE
1315        // réellement AT_SYMLINK_NOFOLLOW / AT_EACCESS — le 3-arg les ignore.
1316        unsafe {
1317            syscall4(
1318                nr::FACCESSAT2,
1319                fd_to_u64(raw_dirfd),
1320                path.as_ptr() as u64,
1321                u64::from(mode.bits()),
1322                i32_to_u64(flags.bits()),
1323            )
1324        }
1325    };
1326
1327    if ret < 0 {
1328        return Err(errno_from_negative_syscall_ret(ret));
1329    }
1330    Ok(())
1331}
1332
1333// ─────────────────────────────────────────────────────────────────────────
1334// Répertoires
1335// ─────────────────────────────────────────────────────────────────────────
1336
1337/// Crée un répertoire.
1338///
1339/// # Errors
1340///
1341/// - `EEXIST` : chemin existant.
1342/// - `ENOENT` : répertoire parent inexistant.
1343/// - `EACCES` : permissions insuffisantes.
1344///
1345/// # Examples
1346///
1347/// ```no_run
1348/// use air_sys_syscall::fs::mkdirat;
1349/// use air_sys_types::fs::DirFd;
1350///
1351/// mkdirat(DirFd::Cwd, c"./newdir", 0o755).expect("mkdirat");
1352/// ```
1353pub fn mkdirat(dirfd: DirFd<'_>, path: &CStr, mode: Mode) -> Result<(), Errno> {
1354    let raw_dirfd = dirfd_to_raw(dirfd);
1355
1356    // SAFETY: path_ptr est un CStr nul-terminé valide.
1357    let ret = unsafe {
1358        syscall3(
1359            nr::MKDIRAT,
1360            fd_to_u64(raw_dirfd),
1361            path.as_ptr() as u64,
1362            u64::from(mode),
1363        )
1364    };
1365
1366    if ret < 0 {
1367        return Err(errno_from_negative_syscall_ret(ret));
1368    }
1369    Ok(())
1370}
1371
1372/// Lit les entrées d'un répertoire.
1373///
1374/// Wrappeur de `getdents64(2)`. Le paramètre `buffer` est un buffer brut
1375/// dont le format binaire est parsé en interne. Utiliser
1376/// [`parse_dirents`] pour itérer sur les entrées.
1377///
1378/// **Allocation intrinsèque :** le buffer est alloué par l'appelant (pas
1379/// de contrainte sur la taille, mais 4096+ octets est recommandé).
1380///
1381/// # Parameters
1382///
1383/// - `fd` : FD d'un répertoire ouvert avec `DIRECTORY`.
1384/// - `buffer` : buffer de lecture (au moins quelques Ko recommandés).
1385///
1386/// # Errors
1387///
1388/// - `EINVAL` : buffer trop petit pour contenir une entrée.
1389/// - `ENOTDIR` : FD n'est pas un répertoire.
1390/// - `EBADF` : FD invalide.
1391///
1392/// # Examples
1393///
1394/// ```no_run
1395/// use air_sys_syscall::fs::{getdents64, parse_dirents};
1396/// use air_sys_types::fd::BorrowedFd;
1397///
1398/// # fn example(dir_fd: BorrowedFd<'_>) {
1399/// let mut buffer = vec![0u8; 4096];
1400/// let n = getdents64(dir_fd, &mut buffer).expect("getdents64");
1401/// for entry in parse_dirents(&buffer[..n]) {
1402///     println!("{:?}", entry.name);
1403/// }
1404/// # }
1405/// ```
1406pub fn getdents64(fd: BorrowedFd<'_>, buffer: &mut [u8]) -> Result<usize, Errno> {
1407    // SAFETY:
1408    // - buffer est un slice mutable valide pour la durée du syscall.
1409    // - getdents64 écrit au plus buffer.len() octets dans buffer.
1410    let ret = unsafe {
1411        syscall3(
1412            nr::GETDENTS64,
1413            fd_to_u64(fd.as_raw_fd()),
1414            buffer.as_mut_ptr() as u64,
1415            buffer.len() as u64,
1416        )
1417    };
1418
1419    if ret < 0 {
1420        return Err(errno_from_negative_syscall_ret(ret));
1421    }
1422
1423    #[allow(clippy::cast_sign_loss)]
1424    ret_to_usize_ok(ret)
1425}
1426
1427/// Itère sur les entrées de répertoire depuis un buffer `getdents64`.
1428///
1429/// Retourne un itérateur sur les [`DirEntry`] parsées depuis `buffer`.
1430/// Le buffer doit être valide pour toute la durée de l'itération.
1431///
1432/// # Examples
1433///
1434/// ```no_run
1435/// use air_sys_syscall::fs::parse_dirents;
1436///
1437/// # fn example(buffer: &[u8]) {
1438/// for entry in parse_dirents(buffer) {
1439///     println!("inode={} name={:?}", entry.inode, entry.name);
1440/// }
1441/// # }
1442/// ```
1443pub fn parse_dirents(buffer: &[u8]) -> impl Iterator<Item = DirEntry> + '_ {
1444    ParseDirents {
1445        buf: buffer,
1446        offset: 0,
1447    }
1448}
1449
1450/// Itérateur sur les entrées `linux_dirent64` dans un buffer brut.
1451struct ParseDirents<'a> {
1452    buf: &'a [u8],
1453    offset: usize,
1454}
1455
1456impl<'a> Iterator for ParseDirents<'a> {
1457    type Item = DirEntry;
1458
1459    fn next(&mut self) -> Option<Self::Item> {
1460        if self.offset >= self.buf.len() {
1461            return None;
1462        }
1463
1464        let buffer = self.buf.get(self.offset..)?;
1465
1466        // struct linux_dirent64 header: ino(8) + offset(8) + reclen(2) + type(1) = 19 bytes minimum
1467        if buffer.len() < 19 {
1468            return None;
1469        }
1470
1471        let ino = u64::from_ne_bytes(buffer.get(0..8)?.try_into().ok()?);
1472        let offset = i64::from_ne_bytes(buffer.get(8..16)?.try_into().ok()?);
1473        let reclen = u16::from_ne_bytes(buffer.get(16..18)?.try_into().ok()?) as usize;
1474        let file_type_byte = *buffer.get(18)?;
1475
1476        if reclen == 0 || reclen > buffer.len() {
1477            return None;
1478        }
1479
1480        // Name starts at byte 19, null-terminated, within the record.
1481        let name_region = buffer.get(19..reclen)?;
1482        let nul_pos = name_region
1483            .iter()
1484            .position(|&b| b == 0)
1485            .unwrap_or(name_region.len());
1486        let name_bytes = name_region.get(..nul_pos)?;
1487
1488        let file_type = match file_type_byte {
1489            1 => DirEntryType::Fifo,
1490            2 => DirEntryType::Character,
1491            4 => DirEntryType::Directory,
1492            6 => DirEntryType::Block,
1493            8 => DirEntryType::Regular,
1494            10 => DirEntryType::Symlink,
1495            12 => DirEntryType::Socket,
1496            _ => DirEntryType::Unknown,
1497        };
1498
1499        // Nom = octets bruts (sans le `NUL`), aucune présomption d'encodage
1500        // (Principe 3, doctrine « chemins = octets ») : copie propriétaire.
1501        let name = name_bytes.to_vec();
1502
1503        self.offset = self.offset.checked_add(reclen)?;
1504
1505        Some(DirEntry {
1506            inode: ino,
1507            offset,
1508            file_type,
1509            name,
1510        })
1511    }
1512}
1513
1514/// Supprime un fichier ou un répertoire vide.
1515///
1516/// Wrappeur de `unlinkat(2)`. Passer `AT_REMOVEDIR` dans `flags` pour
1517/// supprimer un répertoire (équivalent à `rmdir`).
1518///
1519/// # Errors
1520///
1521/// - `ENOENT` : fichier inexistant.
1522/// - `ENOTEMPTY` / `EEXIST` : répertoire non vide (avec `AT_REMOVEDIR`).
1523/// - `EACCES` : permissions insuffisantes.
1524///
1525/// # Examples
1526///
1527/// ```no_run
1528/// use air_sys_syscall::fs::unlinkat;
1529/// use air_sys_types::fs::DirFd;
1530///
1531/// unlinkat(DirFd::Cwd, c"./old.tmp", 0).expect("unlinkat");
1532/// ```
1533pub fn unlinkat(dirfd: DirFd<'_>, path: &CStr, flags: i32) -> Result<(), Errno> {
1534    let raw_dirfd = dirfd_to_raw(dirfd);
1535
1536    // SAFETY: path_ptr est un CStr nul-terminé valide.
1537    let ret = unsafe {
1538        syscall3(
1539            nr::UNLINKAT,
1540            fd_to_u64(raw_dirfd),
1541            path.as_ptr() as u64,
1542            i32_to_u64(flags),
1543        )
1544    };
1545
1546    if ret < 0 {
1547        return Err(errno_from_negative_syscall_ret(ret));
1548    }
1549    Ok(())
1550}
1551
1552/// Renomme (et éventuellement échange) des entrées filesystem.
1553///
1554/// Wrappeur de `renameat2(2)` (Linux 3.15+). `RenameFlags::EXCHANGE`
1555/// permet un échange atomique de deux entrées.
1556///
1557/// # Errors
1558///
1559/// - `EEXIST` : destination existante avec `NOREPLACE`.
1560/// - `ENOENT` : source inexistante.
1561/// - `EXDEV` : source et destination sur des FS différents.
1562///
1563/// # Examples
1564///
1565/// ```no_run
1566/// use air_sys_syscall::fs::renameat2;
1567/// use air_sys_types::fs::{DirFd, RenameFlags};
1568///
1569/// renameat2(DirFd::Cwd, c"./new.tmp", DirFd::Cwd, c"./current", RenameFlags::NOREPLACE)
1570///     .expect("renameat2");
1571/// ```
1572pub fn renameat2(
1573    old_dirfd: DirFd<'_>,
1574    old_path: &CStr,
1575    new_dirfd: DirFd<'_>,
1576    new_path: &CStr,
1577    flags: RenameFlags,
1578) -> Result<(), Errno> {
1579    let raw_old = dirfd_to_raw(old_dirfd);
1580    let raw_new = dirfd_to_raw(new_dirfd);
1581
1582    // SAFETY: old_path et new_path sont des CStr nul-terminés valides.
1583    let ret = unsafe {
1584        syscall5(
1585            nr::RENAMEAT2,
1586            fd_to_u64(raw_old),
1587            old_path.as_ptr() as u64,
1588            fd_to_u64(raw_new),
1589            new_path.as_ptr() as u64,
1590            u64::from(flags.bits()),
1591        )
1592    };
1593
1594    if ret < 0 {
1595        return Err(errno_from_negative_syscall_ret(ret));
1596    }
1597    Ok(())
1598}
1599
1600/// Crée un lien physique.
1601pub fn linkat(
1602    old_dirfd: DirFd<'_>,
1603    old_path: &CStr,
1604    new_dirfd: DirFd<'_>,
1605    new_path: &CStr,
1606    flags: i32,
1607) -> Result<(), Errno> {
1608    let raw_old = dirfd_to_raw(old_dirfd);
1609    let raw_new = dirfd_to_raw(new_dirfd);
1610
1611    // SAFETY: les deux CStr sont nul-terminés et valides pour la durée du syscall.
1612    let ret = unsafe {
1613        syscall5(
1614            nr::LINKAT,
1615            fd_to_u64(raw_old),
1616            old_path.as_ptr() as u64,
1617            fd_to_u64(raw_new),
1618            new_path.as_ptr() as u64,
1619            i32_to_u64(flags),
1620        )
1621    };
1622
1623    if ret < 0 {
1624        return Err(errno_from_negative_syscall_ret(ret));
1625    }
1626    Ok(())
1627}
1628
1629/// Crée un lien symbolique.
1630pub fn symlinkat(target: &CStr, new_dirfd: DirFd<'_>, link_path: &CStr) -> Result<(), Errno> {
1631    let raw_new = dirfd_to_raw(new_dirfd);
1632
1633    // SAFETY: target et link_path sont des CStr nul-terminés valides.
1634    let ret = unsafe {
1635        syscall3(
1636            nr::SYMLINKAT,
1637            target.as_ptr() as u64,
1638            fd_to_u64(raw_new),
1639            link_path.as_ptr() as u64,
1640        )
1641    };
1642
1643    if ret < 0 {
1644        return Err(errno_from_negative_syscall_ret(ret));
1645    }
1646    Ok(())
1647}
1648
1649/// Lit la cible d'un lien symbolique.
1650///
1651/// **Allocation heap :** le résultat est une `Vec<u8>` car la longueur
1652/// n'est connue qu'à l'exécution (nécessité documentée, ADR-021
1653/// convention 4).
1654///
1655/// # Errors
1656///
1657/// - `ENOENT` : lien inexistant.
1658/// - `EINVAL` : le chemin n'est pas un lien symbolique.
1659///
1660/// # Examples
1661///
1662/// ```no_run
1663/// use air_sys_syscall::fs::readlinkat;
1664/// use air_sys_types::fs::DirFd;
1665///
1666/// let target = readlinkat(DirFd::Cwd, c"./link").expect("readlinkat");
1667/// ```
1668pub fn readlinkat(dirfd: DirFd<'_>, path: &CStr) -> Result<Vec<u8>, Errno> {
1669    // Commence avec PATH_MAX (4096). Si la cible est plus longue (rare),
1670    // on double le buffer et on réessaie.
1671    let mut buffer = vec![0u8; 4096];
1672    let raw_dirfd = dirfd_to_raw(dirfd);
1673
1674    loop {
1675        let cap = buffer.len();
1676
1677        // SAFETY:
1678        // - path est un CStr nul-terminé valide.
1679        // - buffer est un Vec mutable de taille `cap`.
1680        // - readlinkat(2) écrit au plus `cap` octets (sans NUL terminal).
1681        let ret = unsafe {
1682            syscall4(
1683                nr::READLINKAT,
1684                fd_to_u64(raw_dirfd),
1685                path.as_ptr() as u64,
1686                buffer.as_mut_ptr() as u64,
1687                cap as u64,
1688            )
1689        };
1690
1691        if ret < 0 {
1692            return Err(errno_from_negative_syscall_ret(ret));
1693        }
1694
1695        let n = ret_to_usize(ret);
1696
1697        if n < cap {
1698            // Résultat tient dans le buffer.
1699            buffer.truncate(n);
1700            return Ok(buffer);
1701        }
1702
1703        // Résultat peut-être tronqué : doubler le buffer et réessayer.
1704        // Checked pour éviter overflow sur des systèmes pathologiques.
1705        let new_cap = cap.checked_mul(2).ok_or_else(|| {
1706            Errno::from_nonzero(NonZeroI32::new(libc_enomem()).expect("ENOMEM non-zero"))
1707        })?;
1708        buffer.resize(new_cap, 0);
1709    }
1710}
1711
1712// ENOMEM = 12, utilisé uniquement dans le chemin d'erreur de readlinkat.
1713#[inline(always)]
1714fn libc_enomem() -> i32 {
1715    12
1716}
1717
1718/// Crée un nœud de périphérique, une FIFO ou un fichier régulier.
1719pub fn mknodat(dirfd: DirFd<'_>, path: &CStr, mode: Mode, dev: u64) -> Result<(), Errno> {
1720    let raw_dirfd = dirfd_to_raw(dirfd);
1721
1722    // SAFETY: path est un CStr nul-terminé valide.
1723    let ret = unsafe {
1724        syscall4(
1725            nr::MKNODAT,
1726            fd_to_u64(raw_dirfd),
1727            path.as_ptr() as u64,
1728            u64::from(mode),
1729            dev,
1730        )
1731    };
1732
1733    if ret < 0 {
1734        return Err(errno_from_negative_syscall_ret(ret));
1735    }
1736    Ok(())
1737}
1738
1739// ─────────────────────────────────────────────────────────────────────────
1740// Permissions
1741// ─────────────────────────────────────────────────────────────────────────
1742
1743/// Modifie les permissions d'un fichier.
1744pub fn fchmodat(dirfd: DirFd<'_>, path: &CStr, mode: Mode, flags: i32) -> Result<(), Errno> {
1745    let raw_dirfd = dirfd_to_raw(dirfd);
1746
1747    // SAFETY: path est un CStr nul-terminé valide.
1748    let ret = unsafe {
1749        syscall4(
1750            nr::FCHMODAT,
1751            fd_to_u64(raw_dirfd),
1752            path.as_ptr() as u64,
1753            u64::from(mode),
1754            i32_to_u64(flags),
1755        )
1756    };
1757
1758    if ret < 0 {
1759        return Err(errno_from_negative_syscall_ret(ret));
1760    }
1761    Ok(())
1762}
1763
1764/// Modifie le propriétaire d'un fichier.
1765pub fn fchownat(
1766    dirfd: DirFd<'_>,
1767    path: &CStr,
1768    uid: u32,
1769    gid: u32,
1770    flags: i32,
1771) -> Result<(), Errno> {
1772    let raw_dirfd = dirfd_to_raw(dirfd);
1773
1774    // SAFETY: path est un CStr nul-terminé valide.
1775    let ret = unsafe {
1776        syscall5(
1777            nr::FCHOWNAT,
1778            fd_to_u64(raw_dirfd),
1779            path.as_ptr() as u64,
1780            u64::from(uid),
1781            u64::from(gid),
1782            i32_to_u64(flags),
1783        )
1784    };
1785
1786    if ret < 0 {
1787        return Err(errno_from_negative_syscall_ret(ret));
1788    }
1789    Ok(())
1790}
1791
1792/// Modifie les horodatages d'accès et de modification d'un fichier.
1793///
1794/// # Parameters
1795///
1796/// - `atime` : nouvel horodatage d'accès (`Now`, `Omit`, ou valeur).
1797/// - `mtime` : nouvel horodatage de modification.
1798///
1799/// # Examples
1800///
1801/// ```no_run
1802/// use air_sys_syscall::fs::utimensat;
1803/// use air_sys_types::fs::{DirFd, UtimeValue};
1804///
1805/// utimensat(DirFd::Cwd, c"./file", UtimeValue::Now, UtimeValue::Omit, 0)
1806///     .expect("utimensat");
1807/// ```
1808pub fn utimensat(
1809    dirfd: DirFd<'_>,
1810    path: &CStr,
1811    atime: UtimeValue,
1812    mtime: UtimeValue,
1813    flags: i32,
1814) -> Result<(), Errno> {
1815    let raw_dirfd = dirfd_to_raw(dirfd);
1816
1817    // struct timespec : [i64; 2] = [tv_sec, tv_nsec]
1818    let atime_ts = utimevalue_to_kernel(atime);
1819    let mtime_ts = utimevalue_to_kernel(mtime);
1820    // Paire consécutive en mémoire, comme struct timespec[2].
1821    let times: [[i64; 2]; 2] = [atime_ts, mtime_ts];
1822
1823    // SAFETY:
1824    // - path est un CStr nul-terminé valide.
1825    // - times est un tableau de deux struct timespec (2×[i64;2]) sur la pile, valide.
1826    // - utimensat(2) lit les deux entrées de times pour mettre à jour les horodatages.
1827    let ret = unsafe {
1828        syscall4(
1829            nr::UTIMENSAT,
1830            fd_to_u64(raw_dirfd),
1831            path.as_ptr() as u64,
1832            times.as_ptr() as u64,
1833            i32_to_u64(flags),
1834        )
1835    };
1836
1837    if ret < 0 {
1838        return Err(errno_from_negative_syscall_ret(ret));
1839    }
1840    Ok(())
1841}
1842
1843/// Modifie les permissions d'un fichier **par descripteur** (`fchmod(2)`) —
1844/// **descellement additif [ADR-085](../../../docs/adrs/ADR-085-descellement-couche0-cumule-libc-std-fr.md)**
1845/// pour la face libc (`int fchmod(int, mode_t)`). Complète `fchmodat` (par chemin).
1846pub fn fchmod(fd: BorrowedFd<'_>, mode: Mode) -> Result<(), Errno> {
1847    // SAFETY: fchmod(2) ne touche aucune mémoire utilisateur.
1848    let ret = unsafe { syscall2(nr::FCHMOD, fd_to_u64(fd.as_raw_fd()), u64::from(mode)) };
1849
1850    if ret < 0 {
1851        return Err(errno_from_negative_syscall_ret(ret));
1852    }
1853    Ok(())
1854}
1855
1856/// Modifie le propriétaire d'un fichier **par descripteur** (`fchown(2)`) —
1857/// descellement additif [ADR-085] pour la face libc (`int fchown(int, uid_t, gid_t)`).
1858/// Complète `fchownat` (par chemin).
1859///
1860/// [ADR-085]: ../../../docs/adrs/ADR-085-descellement-couche0-cumule-libc-std-fr.md
1861pub fn fchown(fd: BorrowedFd<'_>, uid: u32, gid: u32) -> Result<(), Errno> {
1862    // SAFETY: fchown(2) ne touche aucune mémoire utilisateur.
1863    let ret = unsafe {
1864        syscall3(
1865            nr::FCHOWN,
1866            fd_to_u64(fd.as_raw_fd()),
1867            u64::from(uid),
1868            u64::from(gid),
1869        )
1870    };
1871
1872    if ret < 0 {
1873        return Err(errno_from_negative_syscall_ret(ret));
1874    }
1875    Ok(())
1876}
1877
1878/// Modifie les horodatages d'accès/modification d'un fichier **par descripteur**
1879/// (`utimensat(fd, NULL, …)`) — descellement additif [ADR-085] pour la face libc
1880/// (`int futimens(int, const struct timespec[2])`). Complète `utimensat` (dont la
1881/// variante couche 0 exige un `&CStr`, jamais `NULL`).
1882///
1883/// [ADR-085]: ../../../docs/adrs/ADR-085-descellement-couche0-cumule-libc-std-fr.md
1884pub fn futimens(fd: BorrowedFd<'_>, atime: UtimeValue, mtime: UtimeValue) -> Result<(), Errno> {
1885    // struct timespec[2] : [tv_sec, tv_nsec] × 2, consécutifs en mémoire.
1886    let times: [[i64; 2]; 2] = [utimevalue_to_kernel(atime), utimevalue_to_kernel(mtime)];
1887
1888    // SAFETY:
1889    // - pointeur de chemin **NULL** (0) : `utimensat` opère alors sur le fd (`dirfd`
1890    //   emprunté ci-dessus), sémantique exacte de `futimens(3)`.
1891    // - times est un tableau de deux struct timespec sur la pile, valide et lu par le
1892    //   kernel pour poser les horodatages.
1893    let ret = unsafe {
1894        syscall4(
1895            nr::UTIMENSAT,
1896            fd_to_u64(fd.as_raw_fd()),
1897            0, // path == NULL ⇒ opère sur le descripteur
1898            times.as_ptr() as u64,
1899            0, // flags
1900        )
1901    };
1902
1903    if ret < 0 {
1904        return Err(errno_from_negative_syscall_ret(ret));
1905    }
1906    Ok(())
1907}
1908
1909/// Transfert **zéro-copie** kernel de `count` octets de `in_fd` vers `out_fd`
1910/// (`sendfile(2)`) — descellement additif [ADR-085] pour la face libc. `offset` :
1911/// `Some` = position de départ **explicite** (que le kernel met à jour ; la position de
1912/// `in_fd` reste inchangée) ; `None` = position **courante** de `in_fd` (que le kernel
1913/// avance). Rend le nombre d'octets **effectivement** transférés (`0` = fin de `in_fd`,
1914/// transfert possiblement partiel — l'appelant boucle).
1915///
1916/// [ADR-085]: ../../../docs/adrs/ADR-085-descellement-couche0-cumule-libc-std-fr.md
1917pub fn sendfile(
1918    out_fd: BorrowedFd<'_>,
1919    in_fd: BorrowedFd<'_>,
1920    offset: Option<&mut u64>,
1921    count: usize,
1922) -> Result<usize, Errno> {
1923    // `Some` ⇒ pointeur vers l'offset (le kernel le lit **et** y réécrit la nouvelle
1924    // position) ; `None` ⇒ `NULL` (le kernel utilise/avance la position de `in_fd`).
1925    let offset_ptr = match offset {
1926        Some(value) => core::ptr::from_mut(value) as u64,
1927        None => 0,
1928    };
1929    // SAFETY:
1930    // - `out_fd`/`in_fd` sont des `BorrowedFd` valides le temps de l'appel.
1931    // - `offset_ptr` est nul, **ou** pointe un `u64` valide lu/écrit par le kernel.
1932    // - `sendfile(2)` ne touche aucune autre mémoire utilisateur.
1933    let ret = unsafe {
1934        syscall4(
1935            nr::SENDFILE,
1936            fd_to_u64(out_fd.as_raw_fd()),
1937            fd_to_u64(in_fd.as_raw_fd()),
1938            offset_ptr,
1939            count as u64,
1940        )
1941    };
1942
1943    if ret < 0 {
1944        return Err(errno_from_negative_syscall_ret(ret));
1945    }
1946    // `ret >= 0` : octets transférés (au plus `count <= usize::MAX`).
1947    #[allow(clippy::cast_sign_loss)]
1948    ret_to_usize_ok(ret)
1949}
1950
1951fn utimevalue_to_kernel(v: UtimeValue) -> [i64; 2] {
1952    match v {
1953        UtimeValue::Now => [0, UTIME_NOW],
1954        UtimeValue::Omit => [0, UTIME_OMIT],
1955        UtimeValue::Time(ts) => [ts.seconds, i64::from(ts.nanoseconds)],
1956    }
1957}
1958
1959// ─────────────────────────────────────────────────────────────────────────
1960// Opérations spéciales
1961// ─────────────────────────────────────────────────────────────────────────
1962
1963/// Tronque un fichier à une longueur donnée via un FD.
1964///
1965/// # Errors
1966///
1967/// - `EINVAL` : longueur négative.
1968/// - `EBADF` : FD invalide ou non ouvert en écriture.
1969///
1970/// # Examples
1971///
1972/// ```no_run
1973/// use air_sys_syscall::fs::ftruncate;
1974/// use air_sys_types::fd::BorrowedFd;
1975///
1976/// # fn example(fd: BorrowedFd<'_>) {
1977/// ftruncate(fd, 4096).expect("ftruncate");
1978/// # }
1979/// ```
1980pub fn ftruncate(fd: BorrowedFd<'_>, length: i64) -> Result<(), Errno> {
1981    // SAFETY: ftruncate(2) ne touche aucune mémoire utilisateur.
1982    let ret = unsafe { syscall2(nr::FTRUNCATE, fd_to_u64(fd.as_raw_fd()), i64_to_u64(length)) };
1983
1984    if ret < 0 {
1985        return Err(errno_from_negative_syscall_ret(ret));
1986    }
1987    Ok(())
1988}
1989
1990/// Tronque un fichier via son chemin.
1991///
1992/// Note: utilise `openat` + `ftruncate` car `truncate(2)` est un syscall
1993/// legacy non exposé directement (chemin basé). Préférer `ftruncate`.
1994pub fn truncate(dirfd: DirFd<'_>, path: &CStr, length: i64) -> Result<(), Errno> {
1995    use air_sys_types::fs::OpenFlags;
1996    let fd = openat(dirfd, path, OpenFlags::WRONLY, 0)?;
1997    // SAFETY: fd vient d'être ouvert avec succès, BorrowedFd depuis OwnedFd.
1998    let borrowed = unsafe { BorrowedFd::borrow_raw(fd.as_raw_fd()) };
1999    let result = ftruncate(borrowed, length);
2000    drop(fd);
2001    result
2002}
2003
2004/// Force l'écriture des données et métadonnées d'un FD sur le support.
2005///
2006/// # Errors
2007///
2008/// - `EIO` : erreur d'E/S lors de la synchronisation.
2009///
2010/// # Examples
2011///
2012/// ```no_run
2013/// use air_sys_syscall::fs::fsync;
2014/// use air_sys_types::fd::BorrowedFd;
2015///
2016/// # fn example(fd: BorrowedFd<'_>) {
2017/// fsync(fd).expect("fsync");
2018/// # }
2019/// ```
2020pub fn fsync(fd: BorrowedFd<'_>) -> Result<(), Errno> {
2021    // SAFETY: fsync(2) ne touche aucune mémoire utilisateur.
2022    let ret = unsafe { syscall1(nr::FSYNC, fd_to_u64(fd.as_raw_fd())) };
2023
2024    if ret < 0 {
2025        return Err(errno_from_negative_syscall_ret(ret));
2026    }
2027    Ok(())
2028}
2029
2030/// Force l'écriture des données d'un FD (sans les métadonnées non essentielles).
2031pub fn fdatasync(fd: BorrowedFd<'_>) -> Result<(), Errno> {
2032    // SAFETY: fdatasync(2) ne touche aucune mémoire utilisateur.
2033    let ret = unsafe { syscall1(nr::FDATASYNC, fd_to_u64(fd.as_raw_fd())) };
2034
2035    if ret < 0 {
2036        return Err(errno_from_negative_syscall_ret(ret));
2037    }
2038    Ok(())
2039}
2040
2041/// Pré-alloue ou manipule l'espace d'un fichier.
2042///
2043/// # Errors
2044///
2045/// - `EOPNOTSUPP` : opération non supportée par le filesystem.
2046/// - `ENOSPC` : espace insuffisant.
2047///
2048/// # Examples
2049///
2050/// ```no_run
2051/// use air_sys_syscall::fs::fallocate;
2052/// use air_sys_types::fs::FallocateMode;
2053/// use air_sys_types::fd::BorrowedFd;
2054///
2055/// # fn example(fd: BorrowedFd<'_>) {
2056/// // Pré-allouer 1 Mo sans modifier la taille visible
2057/// fallocate(fd, FallocateMode::KEEP_SIZE, 0, 1_048_576).expect("fallocate");
2058/// # }
2059/// ```
2060pub fn fallocate(
2061    fd: BorrowedFd<'_>,
2062    mode: FallocateMode,
2063    offset: i64,
2064    length: i64,
2065) -> Result<(), Errno> {
2066    // SAFETY: fallocate(2) ne touche aucune mémoire utilisateur.
2067    let ret = unsafe {
2068        syscall4(
2069            nr::FALLOCATE,
2070            fd_to_u64(fd.as_raw_fd()),
2071            i32_to_u64(mode.bits()),
2072            i64_to_u64(offset),
2073            i64_to_u64(length),
2074        )
2075    };
2076
2077    if ret < 0 {
2078        return Err(errno_from_negative_syscall_ret(ret));
2079    }
2080    Ok(())
2081}
2082
2083/// Copie zero-copy entre deux fichiers (même FS si possible).
2084///
2085/// Wrappeur de `copy_file_range(2)` (Linux 4.5+).
2086///
2087/// # Errors
2088///
2089/// - `EINVAL` : paramètres invalides.
2090/// - `EXDEV` : FS différents et copy_file_range inter-FS non supporté.
2091///
2092/// # Examples
2093///
2094/// ```no_run
2095/// use air_sys_syscall::fs::copy_file_range;
2096/// use air_sys_types::fd::BorrowedFd;
2097///
2098/// # fn example(src: BorrowedFd<'_>, dst: BorrowedFd<'_>) {
2099/// let n = copy_file_range(src, None, dst, None, 4096, 0)
2100///     .expect("copy_file_range");
2101/// # }
2102/// ```
2103pub fn copy_file_range(
2104    fd_in: BorrowedFd<'_>,
2105    offset_in: Option<&mut u64>,
2106    fd_out: BorrowedFd<'_>,
2107    offset_out: Option<&mut u64>,
2108    length: usize,
2109    flags: u32,
2110) -> Result<usize, Errno> {
2111    let off_in_ptr: u64 = match offset_in {
2112        Some(p) => p as *mut u64 as u64,
2113        None => 0,
2114    };
2115    let off_out_ptr: u64 = match offset_out {
2116        Some(p) => p as *mut u64 as u64,
2117        None => 0,
2118    };
2119
2120    // SAFETY:
2121    // - off_in_ptr et off_out_ptr sont soit NULL (0) soit des pointeurs sur u64 valides.
2122    // - copy_file_range lit/écrit éventuellement les offsets pointés.
2123    // - fd_in et fd_out sont des BorrowedFd valides.
2124    let ret = unsafe {
2125        syscall6(
2126            nr::COPY_FILE_RANGE,
2127            fd_to_u64(fd_in.as_raw_fd()),
2128            off_in_ptr,
2129            fd_to_u64(fd_out.as_raw_fd()),
2130            off_out_ptr,
2131            length as u64,
2132            u64::from(flags),
2133        )
2134    };
2135
2136    if ret < 0 {
2137        return Err(errno_from_negative_syscall_ret(ret));
2138    }
2139
2140    #[allow(clippy::cast_sign_loss)]
2141    ret_to_usize_ok(ret)
2142}
2143
2144/// Pose ou lève un verrou BSD sur un fichier (`flock(2)`).
2145pub fn flock(fd: BorrowedFd<'_>, operation: i32) -> Result<(), Errno> {
2146    // SAFETY: flock(2) ne touche aucune mémoire utilisateur.
2147    let ret = unsafe { syscall2(nr::FLOCK, fd_to_u64(fd.as_raw_fd()), i32_to_u64(operation)) };
2148
2149    if ret < 0 {
2150        return Err(errno_from_negative_syscall_ret(ret));
2151    }
2152    Ok(())
2153}
2154
2155/// Lit les statistiques du filesystem identifié par un chemin.
2156pub fn statfs(path: &CStr) -> Result<StatFsResult, Errno> {
2157    let mut kfs = KernelStatfs {
2158        f_type: 0,
2159        f_bsize: 0,
2160        f_blocks: 0,
2161        f_bfree: 0,
2162        f_bavail: 0,
2163        f_files: 0,
2164        f_ffree: 0,
2165        f_fsid: [0; 2],
2166        f_namelen: 0,
2167        f_frsize: 0,
2168        f_flags: 0,
2169        f_spare: [0; 4],
2170    };
2171
2172    // SAFETY:
2173    // - path est un CStr nul-terminé valide.
2174    // - &mut kfs pointe sur KernelStatfs local (120 octets), correctement aligné.
2175    let ret = unsafe {
2176        syscall2(
2177            nr::STATFS,
2178            path.as_ptr() as u64,
2179            &mut kfs as *mut KernelStatfs as u64,
2180        )
2181    };
2182
2183    if ret < 0 {
2184        return Err(errno_from_negative_syscall_ret(ret));
2185    }
2186
2187    Ok(kernel_statfs_to_result(&kfs))
2188}
2189
2190/// Lit les statistiques du filesystem contenant un FD.
2191pub fn fstatfs(fd: BorrowedFd<'_>) -> Result<StatFsResult, Errno> {
2192    let mut kfs = KernelStatfs {
2193        f_type: 0,
2194        f_bsize: 0,
2195        f_blocks: 0,
2196        f_bfree: 0,
2197        f_bavail: 0,
2198        f_files: 0,
2199        f_ffree: 0,
2200        f_fsid: [0; 2],
2201        f_namelen: 0,
2202        f_frsize: 0,
2203        f_flags: 0,
2204        f_spare: [0; 4],
2205    };
2206
2207    // SAFETY:
2208    // - fd est un BorrowedFd valide.
2209    // - &mut kfs pointe sur KernelStatfs local (120 octets), correctement aligné.
2210    let ret = unsafe {
2211        syscall2(
2212            nr::FSTATFS,
2213            fd_to_u64(fd.as_raw_fd()),
2214            &mut kfs as *mut KernelStatfs as u64,
2215        )
2216    };
2217
2218    if ret < 0 {
2219        return Err(errno_from_negative_syscall_ret(ret));
2220    }
2221
2222    Ok(kernel_statfs_to_result(&kfs))
2223}
2224
2225fn kernel_statfs_to_result(kfs: &KernelStatfs) -> StatFsResult {
2226    StatFsResult {
2227        f_type: FsType(kfs.f_type),
2228        f_bsize: kfs.f_bsize,
2229        f_frsize: kfs.f_frsize,
2230        f_blocks: kfs.f_blocks,
2231        f_bfree: kfs.f_bfree,
2232        f_bavail: kfs.f_bavail,
2233        f_files: kfs.f_files,
2234        f_ffree: kfs.f_ffree,
2235        f_fsid: kfs.f_fsid,
2236        f_namelen: kfs.f_namelen,
2237        f_flags: kfs.f_flags,
2238    }
2239}
2240
2241/// Synchronise une plage d'un fichier sur le disque.
2242///
2243/// Wrappeur de `sync_file_range(2)` (Linux 2.6.17+).
2244///
2245/// # Errors
2246///
2247/// - `EBADF` : FD invalide.
2248/// - `EINVAL` : paramètres invalides.
2249pub fn sync_file_range(
2250    fd: BorrowedFd<'_>,
2251    offset: i64,
2252    nbytes: u64,
2253    flags: u32,
2254) -> Result<(), Errno> {
2255    // SAFETY: sync_file_range(2) ne touche aucune mémoire utilisateur.
2256    let ret = unsafe {
2257        syscall4(
2258            nr::SYNC_FILE_RANGE,
2259            fd_to_u64(fd.as_raw_fd()),
2260            i64_to_u64(offset),
2261            nbytes,
2262            u64::from(flags),
2263        )
2264    };
2265
2266    if ret < 0 {
2267        return Err(errno_from_negative_syscall_ret(ret));
2268    }
2269    Ok(())
2270}
2271
2272// ─────────────────────────────────────────────────────────────────────────
2273// fcntl (opérations individuelles — ADR-021 convention 3)
2274// ─────────────────────────────────────────────────────────────────────────
2275
2276/// Duplique un FD en allouant le plus petit numéro ≥ `min_fd` (`F_DUPFD_CLOEXEC`).
2277///
2278/// # Errors
2279///
2280/// - `EMFILE` : quota FD du processus atteint.
2281/// - `EBADF` : FD invalide.
2282///
2283/// # Examples
2284///
2285/// ```no_run
2286/// use air_sys_syscall::fs::dup_fd;
2287/// use air_sys_types::fd::BorrowedFd;
2288///
2289/// # fn example(fd: BorrowedFd<'_>) {
2290/// let new_fd = dup_fd(fd, 0).expect("dup_fd");
2291/// # }
2292/// ```
2293pub fn dup_fd(fd: BorrowedFd<'_>, min_fd: RawFd) -> Result<OwnedFd, Errno> {
2294    // SAFETY: fcntl F_DUPFD_CLOEXEC ne touche aucune mémoire utilisateur.
2295    let ret = unsafe {
2296        syscall3(
2297            nr::FCNTL,
2298            fd_to_u64(fd.as_raw_fd()),
2299            i32_to_u64(F_DUPFD_CLOEXEC),
2300            fd_to_u64(min_fd),
2301        )
2302    };
2303
2304    if ret < 0 {
2305        return Err(errno_from_negative_syscall_ret(ret));
2306    }
2307
2308    // SAFETY: ret est un fd valide retourné par le kernel.
2309    #[allow(clippy::cast_possible_truncation)]
2310    Ok(unsafe { OwnedFd::from_raw_fd(ret as i32) })
2311}
2312
2313/// Lit les flags du descripteur (`F_GETFD`).
2314pub fn get_fd_flags(fd: BorrowedFd<'_>) -> Result<FdFlags, Errno> {
2315    // SAFETY: F_GETFD ne touche aucune mémoire utilisateur.
2316    let ret = unsafe { syscall3(nr::FCNTL, fd_to_u64(fd.as_raw_fd()), i32_to_u64(F_GETFD), 0) };
2317
2318    if ret < 0 {
2319        return Err(errno_from_negative_syscall_ret(ret));
2320    }
2321
2322    #[allow(clippy::cast_possible_truncation)]
2323    Ok(FdFlags::from_bits_truncate(ret as i32))
2324}
2325
2326/// Modifie les flags du descripteur (`F_SETFD`).
2327pub fn set_fd_flags(fd: BorrowedFd<'_>, flags: FdFlags) -> Result<(), Errno> {
2328    // SAFETY: F_SETFD ne touche aucune mémoire utilisateur.
2329    let ret = unsafe {
2330        syscall3(
2331            nr::FCNTL,
2332            fd_to_u64(fd.as_raw_fd()),
2333            i32_to_u64(F_SETFD),
2334            i32_to_u64(flags.bits()),
2335        )
2336    };
2337
2338    if ret < 0 {
2339        return Err(errno_from_negative_syscall_ret(ret));
2340    }
2341    Ok(())
2342}
2343
2344/// Lit les flags de statut du fichier (`F_GETFL`).
2345pub fn get_status_flags(fd: BorrowedFd<'_>) -> Result<StatusFlags, Errno> {
2346    // SAFETY: F_GETFL ne touche aucune mémoire utilisateur.
2347    let ret = unsafe { syscall3(nr::FCNTL, fd_to_u64(fd.as_raw_fd()), i32_to_u64(F_GETFL), 0) };
2348
2349    if ret < 0 {
2350        return Err(errno_from_negative_syscall_ret(ret));
2351    }
2352
2353    #[allow(clippy::cast_possible_truncation)]
2354    Ok(StatusFlags::from_bits_truncate(ret as i32))
2355}
2356
2357/// Modifie les flags de statut du fichier (`F_SETFL`).
2358pub fn set_status_flags(fd: BorrowedFd<'_>, flags: StatusFlags) -> Result<(), Errno> {
2359    // SAFETY: F_SETFL ne touche aucune mémoire utilisateur.
2360    let ret = unsafe {
2361        syscall3(
2362            nr::FCNTL,
2363            fd_to_u64(fd.as_raw_fd()),
2364            i32_to_u64(F_SETFL),
2365            i32_to_u64(flags.bits()),
2366        )
2367    };
2368
2369    if ret < 0 {
2370        return Err(errno_from_negative_syscall_ret(ret));
2371    }
2372    Ok(())
2373}
2374
2375/// Modifie la capacité d'un pipe (`F_SETPIPE_SZ`).
2376///
2377/// Retourne la nouvelle taille effective (peut être supérieure à `size`).
2378pub fn set_pipe_size(fd: BorrowedFd<'_>, size: usize) -> Result<usize, Errno> {
2379    // SAFETY: F_SETPIPE_SZ ne touche aucune mémoire utilisateur.
2380    let ret = unsafe {
2381        syscall3(
2382            nr::FCNTL,
2383            fd_to_u64(fd.as_raw_fd()),
2384            i32_to_u64(F_SETPIPE_SZ),
2385            size as u64,
2386        )
2387    };
2388
2389    if ret < 0 {
2390        return Err(errno_from_negative_syscall_ret(ret));
2391    }
2392
2393    #[allow(clippy::cast_sign_loss)]
2394    ret_to_usize_ok(ret)
2395}
2396
2397/// Lit la capacité d'un pipe (`F_GETPIPE_SZ`).
2398pub fn get_pipe_size(fd: BorrowedFd<'_>) -> Result<usize, Errno> {
2399    // SAFETY: F_GETPIPE_SZ ne touche aucune mémoire utilisateur.
2400    let ret = unsafe {
2401        syscall3(
2402            nr::FCNTL,
2403            fd_to_u64(fd.as_raw_fd()),
2404            i32_to_u64(F_GETPIPE_SZ),
2405            0,
2406        )
2407    };
2408
2409    if ret < 0 {
2410        return Err(errno_from_negative_syscall_ret(ret));
2411    }
2412
2413    #[allow(clippy::cast_sign_loss)]
2414    ret_to_usize_ok(ret)
2415}
2416
2417/// Tente d'acquérir un verrou POSIX non-bloquant (`F_SETLK`).
2418///
2419/// Retourne `true` si le verrou a été acquis, `false` si bloqué.
2420pub fn try_lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<bool, Errno> {
2421    let kflock = file_lock_to_kernel(lock);
2422
2423    // SAFETY:
2424    // - kflock est une struct KernelFlock valide sur la pile.
2425    // - fcntl F_SETLK lit la struct à l'adresse fournie.
2426    let ret = unsafe {
2427        syscall3(
2428            nr::FCNTL,
2429            fd_to_u64(fd.as_raw_fd()),
2430            i32_to_u64(F_SETLK),
2431            &kflock as *const KernelFlock as u64,
2432        )
2433    };
2434
2435    if ret < 0 {
2436        let err = errno_from_negative_syscall_ret(ret);
2437        // EACCES ou EAGAIN signifient que le verrou est tenu par un autre.
2438        if err == Errno::EACCES || err == Errno::EAGAIN {
2439            return Ok(false);
2440        }
2441        return Err(err);
2442    }
2443    Ok(true)
2444}
2445
2446/// Acquiert un verrou POSIX en mode bloquant (`F_SETLKW`).
2447///
2448/// # Errors
2449///
2450/// - `EINTR` : interrompu par un signal (pas de retry automatique).
2451/// - `EDEADLK` : deadlock détecté.
2452pub fn lock(fd: BorrowedFd<'_>, lock: &FileLock) -> Result<(), Errno> {
2453    let kflock = file_lock_to_kernel(lock);
2454
2455    // SAFETY:
2456    // - kflock est une struct KernelFlock valide sur la pile.
2457    // - fcntl F_SETLKW lit la struct à l'adresse fournie.
2458    let ret = unsafe {
2459        syscall3(
2460            nr::FCNTL,
2461            fd_to_u64(fd.as_raw_fd()),
2462            i32_to_u64(F_SETLKW),
2463            &kflock as *const KernelFlock as u64,
2464        )
2465    };
2466
2467    if ret < 0 {
2468        return Err(errno_from_negative_syscall_ret(ret));
2469    }
2470    Ok(())
2471}
2472
2473fn file_lock_to_kernel(lock: &FileLock) -> KernelFlock {
2474    KernelFlock {
2475        l_type: lock.lock_type as i16,
2476        l_whence: lock.whence as i16,
2477        _pad: 0,
2478        l_start: lock.start,
2479        l_len: lock.length,
2480        l_pid: lock.pid,
2481        _pad2: 0,
2482    }
2483}
2484
2485/// Ajoute des scellements à un `memfd` (`F_ADD_SEALS`).
2486pub fn add_seals(fd: BorrowedFd<'_>, seals: Seals) -> Result<(), Errno> {
2487    // SAFETY: F_ADD_SEALS ne touche aucune mémoire utilisateur.
2488    let ret = unsafe {
2489        syscall3(
2490            nr::FCNTL,
2491            fd_to_u64(fd.as_raw_fd()),
2492            i32_to_u64(F_ADD_SEALS),
2493            u64::from(seals.bits()),
2494        )
2495    };
2496
2497    if ret < 0 {
2498        return Err(errno_from_negative_syscall_ret(ret));
2499    }
2500    Ok(())
2501}
2502
2503/// Lit les scellements actifs d'un `memfd` (`F_GET_SEALS`).
2504pub fn get_seals(fd: BorrowedFd<'_>) -> Result<Seals, Errno> {
2505    // SAFETY: F_GET_SEALS ne touche aucune mémoire utilisateur.
2506    let ret = unsafe {
2507        syscall3(
2508            nr::FCNTL,
2509            fd_to_u64(fd.as_raw_fd()),
2510            i32_to_u64(F_GET_SEALS),
2511            0,
2512        )
2513    };
2514
2515    if ret < 0 {
2516        return Err(errno_from_negative_syscall_ret(ret));
2517    }
2518
2519    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
2520    Ok(Seals::from_bits_truncate(ret as u32))
2521}
2522
2523// ─────────────────────────────────────────────────────────────────────────
2524// Handles persistants
2525// ─────────────────────────────────────────────────────────────────────────
2526
2527/// Obtient un handle persistant pour un fichier.
2528///
2529/// Wrappeur de `name_to_handle_at(2)` (Linux 2.6.39+). Le handle
2530/// retourné identifie le fichier même après renommage.
2531///
2532/// # Errors
2533///
2534/// - `EOPNOTSUPP` : filesystem ne supporte pas les handles.
2535/// - `ENOENT` : fichier inexistant.
2536///
2537/// # Examples
2538///
2539/// ```no_run
2540/// use air_sys_syscall::fs::name_to_handle_at;
2541/// use air_sys_types::fs::{DirFd, NameToHandleFlags};
2542///
2543/// let handle = name_to_handle_at(DirFd::Cwd, c"./data.bin", NameToHandleFlags::empty())
2544///     .expect("name_to_handle_at");
2545/// ```
2546pub fn name_to_handle_at(
2547    dirfd: DirFd<'_>,
2548    path: &CStr,
2549    flags: NameToHandleFlags,
2550) -> Result<FileHandle, Errno> {
2551    let raw_dirfd = dirfd_to_raw(dirfd);
2552    let mut khandle = KernelFileHandle {
2553        handle_bytes: 128,
2554        handle_type: 0,
2555        f_handle: [0u8; 128],
2556    };
2557    let mut mount_id: i32 = 0;
2558
2559    // SAFETY:
2560    // - path est un CStr nul-terminé valide.
2561    // - khandle est initialisé avec handle_bytes=128 (capacité maximale).
2562    // - &mut mount_id est un i32 local valide.
2563    // - Le kernel écrit le type et les octets du handle si handle_bytes suffit.
2564    let ret = unsafe {
2565        syscall5(
2566            nr::NAME_TO_HANDLE_AT,
2567            fd_to_u64(raw_dirfd),
2568            path.as_ptr() as u64,
2569            &mut khandle as *mut KernelFileHandle as u64,
2570            &mut mount_id as *mut i32 as u64,
2571            i32_to_u64(flags.bits()),
2572        )
2573    };
2574
2575    if ret < 0 {
2576        return Err(errno_from_negative_syscall_ret(ret));
2577    }
2578
2579    // handle_bytes est u32, la conversion en usize est exacte sur les cibles 64 bits Air.
2580    #[allow(clippy::cast_lossless)]
2581    let nbytes = khandle.handle_bytes as usize;
2582    let data = khandle
2583        .f_handle
2584        .get(..nbytes.min(128))
2585        .unwrap_or(&khandle.f_handle[..])
2586        .to_vec();
2587
2588    Ok(FileHandle {
2589        handle_type: khandle.handle_type,
2590        handle_bytes: data,
2591        mount_id,
2592    })
2593}
2594
2595/// Ouvre un fichier depuis un handle persistant.
2596///
2597/// # Parameters
2598///
2599/// - `mount_fd` : FD d'un fichier sur le même système de fichiers.
2600/// - `handle` : handle retourné par [`name_to_handle_at`].
2601/// - `flags` : flags d'ouverture.
2602///
2603/// # Errors
2604///
2605/// - `ESTALE` : handle invalide (fichier supprimé ou FS remonté).
2606/// - `EPERM` : `CAP_DAC_READ_SEARCH` requis.
2607///
2608/// # Examples
2609///
2610/// ```no_run
2611/// use air_sys_syscall::fs::open_by_handle_at;
2612/// use air_sys_types::fs::{FileHandle, OpenFlags};
2613/// use air_sys_types::fd::BorrowedFd;
2614///
2615/// # fn example(mount_fd: BorrowedFd<'_>, handle: &FileHandle) {
2616/// let fd = open_by_handle_at(mount_fd, handle, OpenFlags::RDONLY)
2617///     .expect("open_by_handle_at");
2618/// # }
2619/// ```
2620pub fn open_by_handle_at(
2621    mount_fd: BorrowedFd<'_>,
2622    handle: &FileHandle,
2623    flags: OpenFlags,
2624) -> Result<OwnedFd, Errno> {
2625    // Reconstruit une KernelFileHandle depuis le FileHandle Air.
2626    let nbytes = handle.handle_bytes.len().min(128);
2627    // nbytes <= 128, donc la troncature usize → u32 est exacte.
2628    #[allow(clippy::cast_possible_truncation)]
2629    let nbytes_u32 = nbytes as u32;
2630    let mut khandle = KernelFileHandle {
2631        handle_bytes: nbytes_u32,
2632        handle_type: handle.handle_type,
2633        f_handle: [0u8; 128],
2634    };
2635    // SAFETY: nbytes <= 128, donc la copie ne déborde pas.
2636    khandle.f_handle[..nbytes].copy_from_slice(&handle.handle_bytes[..nbytes]);
2637
2638    let raw_flags = flags.bits() | O_CLOEXEC;
2639    #[allow(clippy::cast_possible_truncation)]
2640    let raw_flags_i32 = raw_flags as i32;
2641
2642    // SAFETY:
2643    // - khandle est un KernelFileHandle valide sur la pile.
2644    // - mount_fd est un BorrowedFd valide.
2645    // - Le kernel lit les handle_bytes octets de f_handle.
2646    let ret = unsafe {
2647        syscall3(
2648            nr::OPEN_BY_HANDLE_AT,
2649            fd_to_u64(mount_fd.as_raw_fd()),
2650            &khandle as *const KernelFileHandle as u64,
2651            i32_to_u64(raw_flags_i32),
2652        )
2653    };
2654
2655    if ret < 0 {
2656        return Err(errno_from_negative_syscall_ret(ret));
2657    }
2658
2659    // SAFETY: ret est un fd valide retourné par le kernel après succès.
2660    #[allow(clippy::cast_possible_truncation)]
2661    Ok(unsafe { OwnedFd::from_raw_fd(ret as i32) })
2662}
2663
2664// ─────────────────────────────────────────────────────────────────────────
2665// Tests
2666// ─────────────────────────────────────────────────────────────────────────
2667
2668#[cfg(all(test, target_os = "linux"))]
2669mod tests {
2670    use super::*;
2671    use air_sys_types::fs::{
2672        AccessFlags, AccessMode, DirFd, FallocateMode, FdFlags, FileLock, FsType, LockType,
2673        NameToHandleFlags, OpenFlags, RenameFlags, Seals, SeekWhence, StatusFlags, StatxFlags,
2674        StatxMask, StatxTimestamp, UtimeValue,
2675    };
2676
2677    // errno standard non exposés par l'API publique (gelée). Valeurs
2678    // asm-generic/errno.h, identiques sur x86_64 et aarch64.
2679    const ENOTDIR: Errno = Errno::try_from_raw(20).unwrap();
2680    const EOPNOTSUPP: Errno = Errno::try_from_raw(95).unwrap();
2681
2682    /// Un `BorrowedFd` jamais valide, pour provoquer `EBADF` de façon
2683    /// déterministe. `i32::MAX` dépasse toute limite de fd ouvrable : le
2684    /// kernel rejette systématiquement, sans course de réutilisation
2685    /// (contrairement à un fd réel fermé). `-1` est exclu car c'est la
2686    /// valeur-niche de `BorrowedFd` (`borrow_raw(-1)` panique `fd != -1`).
2687    fn invalid_fd() -> BorrowedFd<'static> {
2688        // SAFETY: aucune ressource n'est lue/écrite via ce fd ; il n'est
2689        // transmis au kernel que pour être rejeté avec `EBADF`.
2690        unsafe { BorrowedFd::borrow_raw(i32::MAX) }
2691    }
2692    use air_sys_types::fd::AsFd;
2693
2694    // ── Helpers ──────────────────────────────────────────────────────────
2695
2696    /// Ouvre un memfd anonyme pour les tests.
2697    fn open_memfd() -> OwnedFd {
2698        // memfd_create = x86_64:319, aarch64:279
2699        #[cfg(target_arch = "x86_64")]
2700        let nr_memfd: i64 = 319;
2701        #[cfg(target_arch = "aarch64")]
2702        let nr_memfd: i64 = 279;
2703
2704        let name = c"test";
2705        // MFD_CLOEXEC = 1
2706        // SAFETY: memfd_create ne touche que le pointeur nom nul-terminé.
2707        let ret = unsafe { syscall2(nr_memfd, name.as_ptr() as u64, 1) };
2708        assert!(ret >= 0, "memfd_create failed: {ret}");
2709        #[allow(clippy::cast_possible_truncation)]
2710        unsafe {
2711            OwnedFd::from_raw_fd(ret as i32)
2712        }
2713    }
2714
2715    // ── openat / openat2 ─────────────────────────────────────────────────
2716
2717    #[test]
2718    fn openat_opens_existing_file() {
2719        let fd = openat(DirFd::Cwd, c"/proc/self/status", OpenFlags::RDONLY, 0);
2720        assert!(fd.is_ok(), "openat /proc/self/status: {:?}", fd);
2721    }
2722
2723    #[test]
2724    fn openat_enoent_on_missing() {
2725        let result = openat(
2726            DirFd::Cwd,
2727            c"/nonexistent_air_test_file",
2728            OpenFlags::RDONLY,
2729            0,
2730        );
2731        assert_eq!(result.unwrap_err(), Errno::ENOENT);
2732    }
2733
2734    #[test]
2735    fn openat2_opens_existing_file() {
2736        use air_sys_types::fs::{OpenHow, ResolveFlags};
2737        let how = OpenHow {
2738            flags: OpenFlags::RDONLY,
2739            mode: 0,
2740            resolve: ResolveFlags::empty(),
2741        };
2742        let fd = openat2(DirFd::Cwd, c"/proc/self/status", how);
2743        assert!(fd.is_ok(), "openat2: {:?}", fd);
2744    }
2745
2746    // ── read / write ─────────────────────────────────────────────────────
2747
2748    #[test]
2749    fn read_write_roundtrip() {
2750        let fd = open_memfd();
2751        let data = b"hello air";
2752        let n = write(fd.as_fd(), data).expect("write");
2753        assert_eq!(n, data.len());
2754
2755        lseek(fd.as_fd(), 0, SeekWhence::Set).expect("lseek");
2756
2757        let mut buffer = [0u8; 64];
2758        let n2 = read(fd.as_fd(), &mut buffer).expect("read");
2759        assert_eq!(n2, data.len());
2760        assert_eq!(&buffer[..n2], data);
2761    }
2762
2763    // ── pread / pwrite ───────────────────────────────────────────────────
2764
2765    #[test]
2766    fn pread_pwrite_roundtrip() {
2767        let fd = open_memfd();
2768        ftruncate(fd.as_fd(), 32).expect("ftruncate");
2769
2770        let n = pwrite(fd.as_fd(), b"abcd", 4).expect("pwrite");
2771        assert_eq!(n, 4);
2772
2773        let mut buffer = [0u8; 4];
2774        let n2 = pread(fd.as_fd(), &mut buffer, 4).expect("pread");
2775        assert_eq!(n2, 4);
2776        assert_eq!(&buffer, b"abcd");
2777    }
2778
2779    // ── lseek ────────────────────────────────────────────────────────────
2780
2781    #[test]
2782    fn lseek_set_and_end() {
2783        let fd = open_memfd();
2784        write(fd.as_fd(), b"0123456789").expect("write");
2785
2786        let pos = lseek(fd.as_fd(), 0, SeekWhence::End).expect("lseek end");
2787        assert_eq!(pos, 10);
2788
2789        let pos2 = lseek(fd.as_fd(), 3, SeekWhence::Set).expect("lseek set");
2790        assert_eq!(pos2, 3);
2791    }
2792
2793    // ── statx ────────────────────────────────────────────────────────────
2794
2795    #[test]
2796    fn statx_on_tmp() {
2797        let result =
2798            statx(DirFd::Cwd, c"/tmp", StatxFlags::empty(), StatxMask::ALL).expect("statx /tmp");
2799        assert!(result.mask.contains(StatxMask::TYPE));
2800    }
2801
2802    // ── mkdirat / unlinkat ───────────────────────────────────────────────
2803
2804    #[test]
2805    fn mkdirat_unlinkat_roundtrip() {
2806        // AT_REMOVEDIR = 0x200
2807        const AT_REMOVEDIR: i32 = 0x200;
2808        let dir = c"/tmp/air_fs_test_dir_mkdirat";
2809        let _ = unlinkat(DirFd::Cwd, dir, AT_REMOVEDIR); // cleanup préventif
2810        mkdirat(DirFd::Cwd, dir, 0o700).expect("mkdirat");
2811        unlinkat(DirFd::Cwd, dir, AT_REMOVEDIR).expect("unlinkat rmdir");
2812    }
2813
2814    // ── getdents64 ───────────────────────────────────────────────────────
2815
2816    #[test]
2817    fn getdents64_lists_entries() {
2818        let fd = openat(
2819            DirFd::Cwd,
2820            c"/tmp",
2821            OpenFlags::RDONLY | OpenFlags::DIRECTORY,
2822            0,
2823        )
2824        .expect("openat /tmp");
2825
2826        let mut buffer = vec![0u8; 4096];
2827        let n = getdents64(fd.as_fd(), &mut buffer).expect("getdents64");
2828        assert!(n > 0, "getdents64 returned 0 bytes");
2829
2830        let entries: Vec<_> = parse_dirents(&buffer[..n]).collect();
2831        assert!(!entries.is_empty(), "pas d'entrées dans /tmp");
2832
2833        // Vérifie que "." et ".." sont présents (noms = octets bruts).
2834        let has_dot = entries.iter().any(|e| e.name == b".");
2835        let has_dotdot = entries.iter().any(|e| e.name == b"..");
2836        assert!(has_dot, ". absent");
2837        assert!(has_dotdot, ".. absent");
2838    }
2839
2840    // ── ftruncate ────────────────────────────────────────────────────────
2841
2842    #[test]
2843    fn ftruncate_sets_size() {
2844        let fd = open_memfd();
2845        ftruncate(fd.as_fd(), 1024).expect("ftruncate");
2846
2847        let pos = lseek(fd.as_fd(), 0, SeekWhence::End).expect("lseek end");
2848        assert_eq!(pos, 1024);
2849    }
2850
2851    // ── fsync ────────────────────────────────────────────────────────────
2852
2853    #[test]
2854    fn fsync_on_memfd() {
2855        let fd = open_memfd();
2856        write(fd.as_fd(), b"data").expect("write");
2857        // Sur memfd fsync est un no-op mais ne doit pas échouer.
2858        fsync(fd.as_fd()).expect("fsync");
2859    }
2860
2861    // ── statfs ───────────────────────────────────────────────────────────
2862
2863    #[test]
2864    fn statfs_root() {
2865        let result = statfs(c"/").expect("statfs /");
2866        assert!(result.f_bsize > 0, "f_bsize nul");
2867        assert!(result.f_blocks > 0, "f_blocks nul");
2868    }
2869
2870    // ── fstatfs ──────────────────────────────────────────────────────────
2871
2872    #[test]
2873    fn fstatfs_on_proc_fd() {
2874        let fd = openat(DirFd::Cwd, c"/proc/self/status", OpenFlags::RDONLY, 0).expect("openat");
2875        let result = fstatfs(fd.as_fd()).expect("fstatfs");
2876        // /proc est monté en procfs (0x9FA0)
2877        assert_eq!(result.f_type, FsType::PROC);
2878    }
2879
2880    // ── copy_file_range ──────────────────────────────────────────────────
2881
2882    #[test]
2883    fn copy_file_range_between_memfds() {
2884        let src = open_memfd();
2885        write(src.as_fd(), b"hello world").expect("write src");
2886        lseek(src.as_fd(), 0, SeekWhence::Set).expect("lseek src");
2887
2888        let dst = open_memfd();
2889
2890        let n =
2891            copy_file_range(src.as_fd(), None, dst.as_fd(), None, 11, 0).expect("copy_file_range");
2892        assert_eq!(n, 11);
2893
2894        lseek(dst.as_fd(), 0, SeekWhence::Set).expect("lseek dst");
2895        let mut buffer = [0u8; 16];
2896        let n2 = read(dst.as_fd(), &mut buffer).expect("read dst");
2897        assert_eq!(&buffer[..n2], b"hello world");
2898    }
2899
2900    // ── dup_fd ───────────────────────────────────────────────────────────
2901
2902    #[test]
2903    fn dup_fd_creates_new_fd() {
2904        let fd = open_memfd();
2905        let fd2 = dup_fd(fd.as_fd(), 0).expect("dup_fd");
2906        // Les deux descripteurs sont distincts mais pointent sur le même fichier.
2907        assert_ne!(fd.as_raw_fd(), fd2.as_raw_fd());
2908        write(fd.as_fd(), b"dup").expect("write original");
2909        lseek(fd2.as_fd(), 0, SeekWhence::Set).expect("lseek dup");
2910        let mut buffer = [0u8; 8];
2911        let n = read(fd2.as_fd(), &mut buffer).expect("read dup");
2912        assert_eq!(&buffer[..n], b"dup");
2913    }
2914
2915    // ── Helper : pipe anonyme ────────────────────────────────────────────
2916
2917    fn make_pipe() -> (OwnedFd, OwnedFd) {
2918        let mut fds = [0i32; 2];
2919        #[cfg(target_arch = "x86_64")]
2920        let nr_pipe2: i64 = 293;
2921        #[cfg(target_arch = "aarch64")]
2922        let nr_pipe2: i64 = 59;
2923        // SAFETY: pipe2 avec O_CLOEXEC = 0o2_000_000.
2924        let ret = unsafe { syscall2(nr_pipe2, fds.as_mut_ptr() as u64, 0o2_000_000) };
2925        assert!(ret >= 0, "pipe2 failed: {ret}");
2926        // SAFETY: les fds ont été remplis par pipe2.
2927        let r = unsafe { OwnedFd::from_raw_fd(fds[0]) };
2928        let w = unsafe { OwnedFd::from_raw_fd(fds[1]) };
2929        (r, w)
2930    }
2931
2932    // ── DirFd::Fd branch ─────────────────────────────────────────────────
2933
2934    #[test]
2935    fn dirfd_fd_branch_enoent() {
2936        let dir = openat(
2937            DirFd::Cwd,
2938            c"/tmp",
2939            OpenFlags::RDONLY | OpenFlags::DIRECTORY,
2940            0,
2941        )
2942        .expect("openat /tmp");
2943        let result = openat(
2944            DirFd::Fd(dir.as_fd()),
2945            c"air_test_no_such_file_dir_fd_42xy",
2946            OpenFlags::RDONLY,
2947            0,
2948        );
2949        assert_eq!(result.unwrap_err(), Errno::ENOENT);
2950    }
2951
2952    // ── close ────────────────────────────────────────────────────────────
2953
2954    #[test]
2955    fn close_valid_fd_succeeds() {
2956        let fd = open_memfd();
2957        close(fd).expect("close");
2958    }
2959
2960    // ── read / write error paths ─────────────────────────────────────────
2961
2962    #[test]
2963    fn read_ebadf_on_invalid_fd() {
2964        let invalid = invalid_fd();
2965        let mut buffer = [0u8; 4];
2966        assert_eq!(read(invalid, &mut buffer).unwrap_err(), Errno::EBADF);
2967    }
2968
2969    #[test]
2970    fn write_ebadf_on_invalid_fd() {
2971        let invalid = invalid_fd();
2972        assert_eq!(write(invalid, b"data").unwrap_err(), Errno::EBADF);
2973    }
2974
2975    // ── pread / pwrite error paths ───────────────────────────────────────
2976
2977    #[test]
2978    fn pread_ebadf_on_invalid_fd() {
2979        let invalid = invalid_fd();
2980        let mut buffer = [0u8; 4];
2981        assert_eq!(pread(invalid, &mut buffer, 0).unwrap_err(), Errno::EBADF);
2982    }
2983
2984    #[test]
2985    fn pwrite_ebadf_on_invalid_fd() {
2986        let invalid = invalid_fd();
2987        assert_eq!(pwrite(invalid, b"data", 0).unwrap_err(), Errno::EBADF);
2988    }
2989
2990    #[test]
2991    fn preadv_ebadf_on_invalid_fd() {
2992        use air_sys_types::net::IoSliceMut;
2993        let invalid = invalid_fd();
2994        let mut buffer = [0u8; 4];
2995        let mut iov = [IoSliceMut::new(&mut buffer)];
2996        assert_eq!(preadv(invalid, &mut iov, 0).unwrap_err(), Errno::EBADF);
2997    }
2998
2999    #[test]
3000    fn pwritev_ebadf_on_invalid_fd() {
3001        use air_sys_types::net::IoSlice;
3002        let invalid = invalid_fd();
3003        let data = *b"data";
3004        let iov = [IoSlice::new(&data)];
3005        assert_eq!(pwritev(invalid, &iov, 0).unwrap_err(), Errno::EBADF);
3006    }
3007
3008    #[test]
3009    fn fallocate_ebadf_on_invalid_fd() {
3010        let invalid = invalid_fd();
3011        let err = fallocate(invalid, FallocateMode::empty(), 0, 4).unwrap_err();
3012        assert_eq!(err, Errno::EBADF);
3013    }
3014
3015    #[test]
3016    fn copy_file_range_ebadf_on_invalid_fd() {
3017        let invalid = invalid_fd();
3018        let err = copy_file_range(invalid, None, invalid, None, 4, 0).unwrap_err();
3019        assert_eq!(err, Errno::EBADF);
3020    }
3021
3022    #[test]
3023    fn sync_file_range_ebadf_on_invalid_fd() {
3024        let invalid = invalid_fd();
3025        let err = sync_file_range(invalid, 0, 0, 0).unwrap_err();
3026        assert_eq!(err, Errno::EBADF);
3027    }
3028
3029    #[test]
3030    fn symlinkat_eexist_when_link_path_already_exists() {
3031        // Couvre la branche d'erreur (`ret < 0`) de `symlinkat` : créer un
3032        // lien dont le `link_path` existe déjà (ici `.` dans le cwd) → EEXIST.
3033        let err = symlinkat(c"target", DirFd::Cwd, c".").unwrap_err();
3034        assert_eq!(err, Errno::EEXIST);
3035    }
3036
3037    // ── readv / writev ───────────────────────────────────────────────────
3038
3039    #[test]
3040    fn readv_writev_roundtrip() {
3041        use air_sys_types::net::{IoSlice, IoSliceMut};
3042        let fd = open_memfd();
3043        let a = b"hello";
3044        let b_data = b" world";
3045        let iov_w = [IoSlice::new(a), IoSlice::new(b_data)];
3046        let n = writev(fd.as_fd(), &iov_w).expect("writev");
3047        assert_eq!(n, 11);
3048        lseek(fd.as_fd(), 0, SeekWhence::Set).expect("lseek");
3049        let mut buf1 = [0u8; 5];
3050        let mut buf2 = [0u8; 6];
3051        let mut iov_r = [IoSliceMut::new(&mut buf1), IoSliceMut::new(&mut buf2)];
3052        let n2 = readv(fd.as_fd(), &mut iov_r).expect("readv");
3053        assert_eq!(n2, 11);
3054        assert_eq!(&buf1, b"hello");
3055        assert_eq!(&buf2, b" world");
3056    }
3057
3058    #[test]
3059    fn readv_ebadf() {
3060        use air_sys_types::net::IoSliceMut;
3061        let invalid = invalid_fd();
3062        let mut buffer = [0u8; 4];
3063        let mut iov = [IoSliceMut::new(&mut buffer)];
3064        assert_eq!(readv(invalid, &mut iov).unwrap_err(), Errno::EBADF);
3065    }
3066
3067    #[test]
3068    fn writev_ebadf() {
3069        use air_sys_types::net::IoSlice;
3070        let invalid = invalid_fd();
3071        let iov = [IoSlice::new(b"data")];
3072        assert_eq!(writev(invalid, &iov).unwrap_err(), Errno::EBADF);
3073    }
3074
3075    // ── preadv / pwritev ─────────────────────────────────────────────────
3076
3077    #[test]
3078    fn preadv_pwritev_roundtrip() {
3079        use air_sys_types::net::{IoSlice, IoSliceMut};
3080        let fd = open_memfd();
3081        ftruncate(fd.as_fd(), 16).expect("ftruncate");
3082        let data = b"abcdef";
3083        let iov_w = [IoSlice::new(data)];
3084        let n = pwritev(fd.as_fd(), &iov_w, 2).expect("pwritev");
3085        assert_eq!(n, 6);
3086        let mut out = [0u8; 6];
3087        let mut iov_r = [IoSliceMut::new(&mut out)];
3088        let n2 = preadv(fd.as_fd(), &mut iov_r, 2).expect("preadv");
3089        assert_eq!(n2, 6);
3090        assert_eq!(&out, b"abcdef");
3091    }
3092
3093    // ── lseek error path ─────────────────────────────────────────────────
3094
3095    #[test]
3096    fn lseek_espipe_on_pipe() {
3097        let (r, _w) = make_pipe();
3098        let err = lseek(r.as_fd(), 0, SeekWhence::Set).unwrap_err();
3099        assert_eq!(err, Errno::ESPIPE);
3100    }
3101
3102    // ── statx error path ─────────────────────────────────────────────────
3103
3104    #[test]
3105    fn statx_enoent_on_missing_file() {
3106        let err = statx(
3107            DirFd::Cwd,
3108            c"/air_no_such_file_statx_42",
3109            StatxFlags::empty(),
3110            StatxMask::ALL,
3111        )
3112        .unwrap_err();
3113        assert_eq!(err, Errno::ENOENT);
3114    }
3115
3116    // ── faccessat ────────────────────────────────────────────────────────
3117
3118    #[test]
3119    fn faccessat_existing_file_ok() {
3120        faccessat(
3121            DirFd::Cwd,
3122            c"/proc/self/status",
3123            AccessMode::F_OK,
3124            AccessFlags::empty(),
3125        )
3126        .expect("faccessat /proc/self/status");
3127    }
3128
3129    #[test]
3130    fn faccessat_missing_file_enoent() {
3131        let err = faccessat(
3132            DirFd::Cwd,
3133            c"/air_no_such_file_faccessat_42",
3134            AccessMode::F_OK,
3135            AccessFlags::empty(),
3136        )
3137        .unwrap_err();
3138        assert_eq!(err, Errno::ENOENT);
3139    }
3140
3141    #[test]
3142    fn faccessat_symlink_nofollow_effective() {
3143        // Lien symbolique pendouillant : la cible n'existe pas, mais le lien
3144        // lui-même existe. C'est LE test qui distingue le comportement corrigé
3145        // (route vers faccessat2, 4-arg, qui HONORE le flag) de l'ancien (buggé,
3146        // qui passait le flag à faccessat 3-arg → silencieusement ignoré). Avant
3147        // le fix, l'assertion `Ok` ci-dessous échouait avec ENOENT.
3148        let link = c"/tmp/air_fs_faccessat_dangling_42";
3149        let target = c"/air_no_such_target_for_dangling_symlink_42";
3150        let _ = unlinkat(DirFd::Cwd, link, 0);
3151        symlinkat(target, DirFd::Cwd, link).expect("crée le lien pendouillant");
3152
3153        // Avec SYMLINK_NOFOLLOW → on teste le lien lui-même, qui existe → Ok.
3154        faccessat(
3155            DirFd::Cwd,
3156            link,
3157            AccessMode::F_OK,
3158            AccessFlags::SYMLINK_NOFOLLOW,
3159        )
3160        .expect("le lien lui-même existe : F_OK doit réussir avec NOFOLLOW");
3161
3162        // Sans flag → on suit le lien vers une cible absente → ENOENT.
3163        let err = faccessat(DirFd::Cwd, link, AccessMode::F_OK, AccessFlags::empty()).unwrap_err();
3164        assert_eq!(
3165            err,
3166            Errno::ENOENT,
3167            "sans NOFOLLOW, on suit vers la cible absente"
3168        );
3169
3170        let _ = unlinkat(DirFd::Cwd, link, 0);
3171    }
3172
3173    #[test]
3174    fn faccessat_eaccess_flag_routes_and_succeeds() {
3175        // AT_EACCESS force l'usage des UID/GID effectifs. Sur un fichier
3176        // lisible (`/proc/self/status`), R_OK doit réussir — et l'appel emprunte
3177        // la branche faccessat2 (flags non vide), exerçant le routage 4-arg.
3178        faccessat(
3179            DirFd::Cwd,
3180            c"/proc/self/status",
3181            AccessMode::R_OK,
3182            AccessFlags::EACCESS,
3183        )
3184        .expect("R_OK avec EACCESS sur un fichier lisible");
3185    }
3186
3187    // ── mkdirat error path ────────────────────────────────────────────────
3188
3189    #[test]
3190    fn mkdirat_eexist_on_second_call() {
3191        const AT_REMOVEDIR: i32 = 0x200;
3192        let dir = c"/tmp/air_fs_test_dir_eexist_42";
3193        let _ = unlinkat(DirFd::Cwd, dir, AT_REMOVEDIR);
3194        mkdirat(DirFd::Cwd, dir, 0o700).expect("mkdirat première fois");
3195        let err = mkdirat(DirFd::Cwd, dir, 0o700).unwrap_err();
3196        assert_eq!(err, Errno::EEXIST);
3197        unlinkat(DirFd::Cwd, dir, AT_REMOVEDIR).expect("cleanup");
3198    }
3199
3200    // ── getdents64 error path ─────────────────────────────────────────────
3201
3202    #[test]
3203    fn getdents64_enotdir_on_regular_file() {
3204        let fd = open_memfd();
3205        let mut buffer = vec![0u8; 4096];
3206        let err = getdents64(fd.as_fd(), &mut buffer).unwrap_err();
3207        assert_eq!(err, ENOTDIR);
3208    }
3209
3210    // ── renameat2 ─────────────────────────────────────────────────────────
3211
3212    #[test]
3213    fn renameat2_renames_file() {
3214        let src = c"/tmp/air_fs_rename_src_42";
3215        let dst = c"/tmp/air_fs_rename_dst_42";
3216        let _ = unlinkat(DirFd::Cwd, src, 0);
3217        let _ = unlinkat(DirFd::Cwd, dst, 0);
3218        let fd = openat(
3219            DirFd::Cwd,
3220            src,
3221            OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3222            0o600,
3223        )
3224        .expect("crée src");
3225        drop(fd);
3226        renameat2(DirFd::Cwd, src, DirFd::Cwd, dst, RenameFlags::empty()).expect("renameat2");
3227        // dst existe maintenant, src non.
3228        faccessat(DirFd::Cwd, dst, AccessMode::F_OK, AccessFlags::empty())
3229            .expect("dst doit exister");
3230        let _ = unlinkat(DirFd::Cwd, dst, 0);
3231    }
3232
3233    #[test]
3234    fn renameat2_noreplace_eexist() {
3235        let src = c"/tmp/air_fs_noreplace_src_42";
3236        let dst = c"/tmp/air_fs_noreplace_dst_42";
3237        let _ = unlinkat(DirFd::Cwd, src, 0);
3238        let _ = unlinkat(DirFd::Cwd, dst, 0);
3239        for p in [src, dst] {
3240            let fd = openat(
3241                DirFd::Cwd,
3242                p,
3243                OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3244                0o600,
3245            )
3246            .expect("crée fichier");
3247            drop(fd);
3248        }
3249        let err = renameat2(DirFd::Cwd, src, DirFd::Cwd, dst, RenameFlags::NOREPLACE).unwrap_err();
3250        assert_eq!(err, Errno::EEXIST);
3251        let _ = unlinkat(DirFd::Cwd, src, 0);
3252        let _ = unlinkat(DirFd::Cwd, dst, 0);
3253    }
3254
3255    // ── linkat / symlinkat / readlinkat ───────────────────────────────────
3256
3257    #[test]
3258    fn linkat_creates_hard_link() {
3259        let src = c"/tmp/air_fs_linkat_src_42";
3260        let lnk = c"/tmp/air_fs_linkat_lnk_42";
3261        let _ = unlinkat(DirFd::Cwd, src, 0);
3262        let _ = unlinkat(DirFd::Cwd, lnk, 0);
3263        let fd = openat(
3264            DirFd::Cwd,
3265            src,
3266            OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3267            0o600,
3268        )
3269        .expect("crée src");
3270        drop(fd);
3271        linkat(DirFd::Cwd, src, DirFd::Cwd, lnk, 0).expect("linkat");
3272        let _ = unlinkat(DirFd::Cwd, src, 0);
3273        let _ = unlinkat(DirFd::Cwd, lnk, 0);
3274    }
3275
3276    #[test]
3277    fn linkat_enoent_on_missing_source() {
3278        let err = linkat(
3279            DirFd::Cwd,
3280            c"/air_no_such_linkat_src",
3281            DirFd::Cwd,
3282            c"/tmp/air_no_such_linkat_dst",
3283            0,
3284        )
3285        .unwrap_err();
3286        assert_eq!(err, Errno::ENOENT);
3287    }
3288
3289    #[test]
3290    fn symlinkat_readlinkat_roundtrip() {
3291        let target = c"/proc/self/status";
3292        let link = c"/tmp/air_fs_symlink_42";
3293        let _ = unlinkat(DirFd::Cwd, link, 0);
3294        symlinkat(target, DirFd::Cwd, link).expect("symlinkat");
3295        let read_back = readlinkat(DirFd::Cwd, link).expect("readlinkat");
3296        assert_eq!(read_back.as_slice(), b"/proc/self/status");
3297        let _ = unlinkat(DirFd::Cwd, link, 0);
3298    }
3299
3300    #[test]
3301    fn readlinkat_einval_on_non_symlink() {
3302        let err = readlinkat(DirFd::Cwd, c"/proc/self/status").unwrap_err();
3303        assert_eq!(err, Errno::EINVAL);
3304    }
3305
3306    #[test]
3307    fn readlinkat_enoent_on_missing() {
3308        let err = readlinkat(DirFd::Cwd, c"/air_no_such_symlink_42").unwrap_err();
3309        assert_eq!(err, Errno::ENOENT);
3310    }
3311
3312    /// `libc_enomem` est la constante `ENOMEM` (12) du chemin de croissance de
3313    /// buffer de `readlinkat`. Ce chemin est **structurellement inatteignable**
3314    /// sur les FS cibles (cible de symlink ≤ `PATH_MAX-1` < buffer initial — cf.
3315    /// registre `readlinkat`), donc le helper n'est jamais appelé en production ;
3316    /// il reste néanmoins déterministe et testable directement.
3317    #[test]
3318    fn libc_enomem_is_twelve() {
3319        assert_eq!(libc_enomem(), 12, "ENOMEM (asm-generic/errno.h)");
3320    }
3321
3322    // ── mknodat ──────────────────────────────────────────────────────────
3323
3324    #[test]
3325    fn mknodat_creates_fifo() {
3326        let path = c"/tmp/air_fs_mknodat_fifo_42";
3327        let _ = unlinkat(DirFd::Cwd, path, 0);
3328        // S_IFIFO | 0o600 = 0o10600
3329        mknodat(DirFd::Cwd, path, 0o10_600, 0).expect("mknodat FIFO");
3330        let _ = unlinkat(DirFd::Cwd, path, 0);
3331    }
3332
3333    #[test]
3334    fn mknodat_eexist_on_second_call() {
3335        let path = c"/tmp/air_fs_mknodat_eexist_42";
3336        let _ = unlinkat(DirFd::Cwd, path, 0);
3337        mknodat(DirFd::Cwd, path, 0o10_600, 0).expect("mknodat première fois");
3338        let err = mknodat(DirFd::Cwd, path, 0o10_600, 0).unwrap_err();
3339        assert_eq!(err, Errno::EEXIST);
3340        let _ = unlinkat(DirFd::Cwd, path, 0);
3341    }
3342
3343    // ── fchmodat ─────────────────────────────────────────────────────────
3344
3345    #[test]
3346    fn fchmodat_changes_mode() {
3347        let path = c"/tmp/air_fs_fchmodat_42";
3348        let _ = unlinkat(DirFd::Cwd, path, 0);
3349        let fd = openat(
3350            DirFd::Cwd,
3351            path,
3352            OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3353            0o644,
3354        )
3355        .expect("crée fichier");
3356        drop(fd);
3357        fchmodat(DirFd::Cwd, path, 0o600, 0).expect("fchmodat");
3358        let _ = unlinkat(DirFd::Cwd, path, 0);
3359    }
3360
3361    #[test]
3362    fn fchmodat_enoent_on_missing() {
3363        let err = fchmodat(DirFd::Cwd, c"/air_no_such_file_fchmodat_42", 0o644, 0).unwrap_err();
3364        assert_eq!(err, Errno::ENOENT);
3365    }
3366
3367    // ── fchownat ─────────────────────────────────────────────────────────
3368
3369    #[test]
3370    fn fchownat_no_change_succeeds() {
3371        // uid=-1/gid=-1 signifie "ne pas changer" sur Linux (4294967295 en u32).
3372        let path = c"/tmp/air_fs_fchownat_42";
3373        let _ = unlinkat(DirFd::Cwd, path, 0);
3374        let fd = openat(
3375            DirFd::Cwd,
3376            path,
3377            OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3378            0o644,
3379        )
3380        .expect("crée fichier");
3381        drop(fd);
3382        fchownat(DirFd::Cwd, path, u32::MAX, u32::MAX, 0).expect("fchownat no-op");
3383        let _ = unlinkat(DirFd::Cwd, path, 0);
3384    }
3385
3386    #[test]
3387    fn fchownat_enoent_on_missing() {
3388        let err = fchownat(
3389            DirFd::Cwd,
3390            c"/air_no_such_file_fchownat_42",
3391            u32::MAX,
3392            u32::MAX,
3393            0,
3394        )
3395        .unwrap_err();
3396        assert_eq!(err, Errno::ENOENT);
3397    }
3398
3399    // ── fchmod / fchown / futimens (par fd, additif ADR-085) ──────────────
3400
3401    #[test]
3402    fn fchmod_changes_mode_by_fd() {
3403        let fd = open_memfd();
3404        fchmod(fd.as_fd(), 0o600).expect("fchmod par fd");
3405        // fd invalide ⇒ `EBADF`.
3406        assert_eq!(fchmod(invalid_fd(), 0o600).unwrap_err(), Errno::EBADF);
3407    }
3408
3409    #[test]
3410    fn fchown_noop_by_fd() {
3411        let fd = open_memfd();
3412        // `(uid, gid) = (-1, -1)` ⇒ « ne rien changer » : succès **sans privilège**.
3413        fchown(fd.as_fd(), u32::MAX, u32::MAX).expect("fchown no-op par fd");
3414        // fd invalide ⇒ `EBADF`.
3415        assert_eq!(
3416            fchown(invalid_fd(), u32::MAX, u32::MAX).unwrap_err(),
3417            Errno::EBADF
3418        );
3419    }
3420
3421    #[test]
3422    fn futimens_sets_times_by_fd() {
3423        let fd = open_memfd();
3424        futimens(fd.as_fd(), UtimeValue::Now, UtimeValue::Omit).expect("futimens par fd");
3425        // fd invalide ⇒ `EBADF`.
3426        assert_eq!(
3427            futimens(invalid_fd(), UtimeValue::Now, UtimeValue::Now).unwrap_err(),
3428            Errno::EBADF
3429        );
3430    }
3431
3432    #[test]
3433    fn sendfile_transfers_with_and_without_offset() {
3434        let source = open_memfd();
3435        let data = b"SENDFILE";
3436        assert_eq!(write(source.as_fd(), data).expect("write source"), 8);
3437
3438        // Avec `offset` explicite : départ à 0, l'offset est **avancé** ; la position de
3439        // `source` reste inchangée.
3440        let sink = open_memfd();
3441        let mut offset = 0_u64;
3442        let sent = sendfile(sink.as_fd(), source.as_fd(), Some(&mut offset), 8).expect("sendfile");
3443        assert_eq!(sent, 8);
3444        assert_eq!(offset, 8, "l'offset explicite est avancé");
3445        // Relit le puits pour vérifier le contenu.
3446        lseek(sink.as_fd(), 0, SeekWhence::Set).expect("lseek sink");
3447        let mut buffer = [0_u8; 8];
3448        assert_eq!(read(sink.as_fd(), &mut buffer).expect("read sink"), 8);
3449        assert_eq!(&buffer, b"SENDFILE");
3450
3451        // Sans `offset` (`None`) : le kernel part de la position **courante** de `source`
3452        // (remise à 0) et l'avance.
3453        lseek(source.as_fd(), 0, SeekWhence::Set).expect("lseek source");
3454        let sink2 = open_memfd();
3455        assert_eq!(
3456            sendfile(sink2.as_fd(), source.as_fd(), None, 8).expect("sendfile sans offset"),
3457            8
3458        );
3459
3460        // fd invalide ⇒ `EBADF`.
3461        assert_eq!(
3462            sendfile(invalid_fd(), source.as_fd(), None, 8).unwrap_err(),
3463            Errno::EBADF
3464        );
3465    }
3466
3467    // ── utimensat ────────────────────────────────────────────────────────
3468
3469    #[test]
3470    fn utimensat_now_and_omit() {
3471        let path = c"/tmp/air_fs_utimensat_42";
3472        let _ = unlinkat(DirFd::Cwd, path, 0);
3473        let fd = openat(
3474            DirFd::Cwd,
3475            path,
3476            OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3477            0o644,
3478        )
3479        .expect("crée fichier");
3480        drop(fd);
3481        utimensat(DirFd::Cwd, path, UtimeValue::Now, UtimeValue::Omit, 0)
3482            .expect("utimensat Now/Omit");
3483        let _ = unlinkat(DirFd::Cwd, path, 0);
3484    }
3485
3486    #[test]
3487    fn utimensat_specific_time() {
3488        let path = c"/tmp/air_fs_utimensat_time_42";
3489        let _ = unlinkat(DirFd::Cwd, path, 0);
3490        let fd = openat(
3491            DirFd::Cwd,
3492            path,
3493            OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3494            0o644,
3495        )
3496        .expect("crée fichier");
3497        drop(fd);
3498        let ts = StatxTimestamp {
3499            seconds: 1_000_000,
3500            nanoseconds: 123_456_789,
3501        };
3502        utimensat(DirFd::Cwd, path, UtimeValue::Time(ts), UtimeValue::Omit, 0)
3503            .expect("utimensat Time");
3504        let _ = unlinkat(DirFd::Cwd, path, 0);
3505    }
3506
3507    #[test]
3508    fn utimensat_enoent_on_missing() {
3509        let err = utimensat(
3510            DirFd::Cwd,
3511            c"/air_no_such_utimensat_42",
3512            UtimeValue::Now,
3513            UtimeValue::Now,
3514            0,
3515        )
3516        .unwrap_err();
3517        assert_eq!(err, Errno::ENOENT);
3518    }
3519
3520    // ── truncate ─────────────────────────────────────────────────────────
3521
3522    #[test]
3523    fn truncate_via_path_sets_size() {
3524        let path = c"/tmp/air_fs_truncate_42";
3525        let _ = unlinkat(DirFd::Cwd, path, 0);
3526        let fd = openat(
3527            DirFd::Cwd,
3528            path,
3529            OpenFlags::WRONLY | OpenFlags::CREAT | OpenFlags::EXCL,
3530            0o644,
3531        )
3532        .expect("crée fichier");
3533        write(fd.as_fd(), b"hello world 12345").expect("write");
3534        drop(fd);
3535        truncate(DirFd::Cwd, path, 5).expect("truncate");
3536        let fd2 = openat(DirFd::Cwd, path, OpenFlags::RDONLY, 0).expect("reopen");
3537        let pos = lseek(fd2.as_fd(), 0, SeekWhence::End).expect("lseek end");
3538        assert_eq!(pos, 5);
3539        let _ = unlinkat(DirFd::Cwd, path, 0);
3540    }
3541
3542    #[test]
3543    fn truncate_enoent_on_missing() {
3544        let err = truncate(DirFd::Cwd, c"/air_no_such_truncate_42", 0).unwrap_err();
3545        assert_eq!(err, Errno::ENOENT);
3546    }
3547
3548    // ── fdatasync ────────────────────────────────────────────────────────
3549
3550    #[test]
3551    fn fdatasync_on_memfd() {
3552        let fd = open_memfd();
3553        write(fd.as_fd(), b"data").expect("write");
3554        fdatasync(fd.as_fd()).expect("fdatasync");
3555    }
3556
3557    #[test]
3558    fn fdatasync_ebadf() {
3559        let invalid = invalid_fd();
3560        assert_eq!(fdatasync(invalid).unwrap_err(), Errno::EBADF);
3561    }
3562
3563    // ── fallocate ────────────────────────────────────────────────────────
3564
3565    #[test]
3566    fn fallocate_preallocates_space() {
3567        let fd = open_memfd();
3568        fallocate(fd.as_fd(), FallocateMode::empty(), 0, 4096).expect("fallocate");
3569        let pos = lseek(fd.as_fd(), 0, SeekWhence::End).expect("lseek");
3570        assert_eq!(pos, 4096);
3571    }
3572
3573    #[test]
3574    fn fallocate_keep_size_does_not_extend() {
3575        let fd = open_memfd();
3576        fallocate(fd.as_fd(), FallocateMode::KEEP_SIZE, 0, 4096).expect("fallocate KEEP_SIZE");
3577        // La taille visible reste 0 avec KEEP_SIZE.
3578        let pos = lseek(fd.as_fd(), 0, SeekWhence::End).expect("lseek");
3579        assert_eq!(pos, 0);
3580    }
3581
3582    // ── flock ────────────────────────────────────────────────────────────
3583
3584    #[test]
3585    fn flock_lock_and_unlock() {
3586        let fd = open_memfd();
3587        // LOCK_EX = 2 ; LOCK_UN = 8
3588        flock(fd.as_fd(), 2).expect("flock LOCK_EX");
3589        flock(fd.as_fd(), 8).expect("flock LOCK_UN");
3590    }
3591
3592    #[test]
3593    fn flock_ebadf() {
3594        let invalid = invalid_fd();
3595        assert_eq!(flock(invalid, 2).unwrap_err(), Errno::EBADF);
3596    }
3597
3598    // ── sync_file_range ──────────────────────────────────────────────────
3599
3600    #[test]
3601    fn sync_file_range_on_memfd() {
3602        let fd = open_memfd();
3603        write(fd.as_fd(), b"sync data").expect("write");
3604        // SYNC_FILE_RANGE_WRITE = 2
3605        sync_file_range(fd.as_fd(), 0, 0, 2).expect("sync_file_range");
3606    }
3607
3608    // ── get_fd_flags / set_fd_flags ───────────────────────────────────────
3609
3610    #[test]
3611    fn fd_flags_get_set_roundtrip() {
3612        let fd = open_memfd();
3613        // Les memfds sont créés avec FD_CLOEXEC.
3614        let flags = get_fd_flags(fd.as_fd()).expect("get_fd_flags");
3615        // FD_CLOEXEC = 1
3616        assert!(flags.contains(FdFlags::CLOEXEC));
3617        // Retire FD_CLOEXEC et vérifie.
3618        set_fd_flags(fd.as_fd(), FdFlags::empty()).expect("set_fd_flags");
3619        let flags2 = get_fd_flags(fd.as_fd()).expect("get_fd_flags après set");
3620        assert!(!flags2.contains(FdFlags::CLOEXEC));
3621    }
3622
3623    #[test]
3624    fn get_fd_flags_ebadf() {
3625        let invalid = invalid_fd();
3626        assert_eq!(get_fd_flags(invalid).unwrap_err(), Errno::EBADF);
3627    }
3628
3629    #[test]
3630    fn set_fd_flags_ebadf() {
3631        let invalid = invalid_fd();
3632        assert_eq!(
3633            set_fd_flags(invalid, FdFlags::empty()).unwrap_err(),
3634            Errno::EBADF
3635        );
3636    }
3637
3638    // ── get_status_flags / set_status_flags ───────────────────────────────
3639
3640    #[test]
3641    fn status_flags_get_set_roundtrip() {
3642        let fd = open_memfd();
3643        let flags = get_status_flags(fd.as_fd()).expect("get_status_flags");
3644        // RDWR = O_RDWR ; on vérifie juste que l'appel réussit.
3645        set_status_flags(fd.as_fd(), flags).expect("set_status_flags");
3646    }
3647
3648    #[test]
3649    fn get_status_flags_ebadf() {
3650        let invalid = invalid_fd();
3651        assert_eq!(get_status_flags(invalid).unwrap_err(), Errno::EBADF);
3652    }
3653
3654    #[test]
3655    fn set_status_flags_ebadf() {
3656        let invalid = invalid_fd();
3657        assert_eq!(
3658            set_status_flags(invalid, StatusFlags::empty()).unwrap_err(),
3659            Errno::EBADF
3660        );
3661    }
3662
3663    // ── set_pipe_size / get_pipe_size ─────────────────────────────────────
3664
3665    #[test]
3666    fn pipe_size_set_get_roundtrip() {
3667        let (r, _w) = make_pipe();
3668        let new_size = set_pipe_size(r.as_fd(), 65536).expect("set_pipe_size");
3669        assert!(new_size >= 65536);
3670        let got = get_pipe_size(r.as_fd()).expect("get_pipe_size");
3671        assert_eq!(got, new_size);
3672    }
3673
3674    #[test]
3675    fn set_pipe_size_ebadf() {
3676        let invalid = invalid_fd();
3677        assert_eq!(set_pipe_size(invalid, 4096).unwrap_err(), Errno::EBADF);
3678    }
3679
3680    #[test]
3681    fn get_pipe_size_ebadf() {
3682        let invalid = invalid_fd();
3683        assert_eq!(get_pipe_size(invalid).unwrap_err(), Errno::EBADF);
3684    }
3685
3686    // ── try_lock / lock ──────────────────────────────────────────────────
3687
3688    #[test]
3689    fn try_lock_and_unlock() {
3690        let path = c"/tmp/air_fs_try_lock_42";
3691        let _ = unlinkat(DirFd::Cwd, path, 0);
3692        let fd = openat(
3693            DirFd::Cwd,
3694            path,
3695            OpenFlags::RDWR | OpenFlags::CREAT | OpenFlags::EXCL,
3696            0o644,
3697        )
3698        .expect("crée fichier");
3699        let lock_w = FileLock {
3700            lock_type: LockType::Write,
3701            whence: SeekWhence::Set,
3702            start: 0,
3703            length: 0,
3704            pid: 0,
3705        };
3706        let locked = try_lock(fd.as_fd(), &lock_w).expect("try_lock Write");
3707        assert!(locked, "devrait acquérir le verrou write");
3708        let unlock = FileLock {
3709            lock_type: LockType::Unlock,
3710            whence: SeekWhence::Set,
3711            start: 0,
3712            length: 0,
3713            pid: 0,
3714        };
3715        lock(fd.as_fd(), &unlock).expect("lock Unlock");
3716        let _ = unlinkat(DirFd::Cwd, path, 0);
3717    }
3718
3719    /// Contention **réelle** : les verrous POSIX `fcntl` sont **per-process**. Un
3720    /// enfant forké prend le verrou exclusif et le tient ; le parent `try_lock` →
3721    /// **`Ok(false)`** (couvre le chemin de contention `EACCES`/`EAGAIN`, jadis
3722    /// exempté faute de harnais multi-processus). Synchronisé par pipes (pas de
3723    /// sleep), récolté via `waitid`.
3724    #[test]
3725    #[cfg_attr(miri, ignore = "fork/verrous fcntl non supportés par Miri")]
3726    fn try_lock_returns_ok_false_when_held_by_another_process() {
3727        use crate::ipc::pipe2;
3728        use crate::process::{clone3, exit_group, waitid};
3729        use air_sys_types::{
3730            CloneArgs, CloneFlags, CloneResult, PipeFlags, Signal, WaitEvent, WaitOptions,
3731            WaitTarget,
3732        };
3733
3734        let path = c"/tmp/air_fs_try_lock_contention_55";
3735        let _ = unlinkat(DirFd::Cwd, path, 0);
3736        let fd = openat(
3737            DirFd::Cwd,
3738            path,
3739            OpenFlags::RDWR | OpenFlags::CREAT | OpenFlags::EXCL,
3740            0o644,
3741        )
3742        .expect("crée fichier");
3743        // `locked` : enfant→parent (« j'ai le verrou ») ; `release` : parent→enfant.
3744        let (locked_r, locked_w) = pipe2(PipeFlags::empty()).expect("pipe locked");
3745        let (release_r, release_w) = pipe2(PipeFlags::empty()).expect("pipe release");
3746
3747        let write_lock = FileLock {
3748            lock_type: LockType::Write,
3749            whence: SeekWhence::Set,
3750            start: 0,
3751            length: 0,
3752            pid: 0,
3753        };
3754
3755        let args = CloneArgs {
3756            flags: CloneFlags::empty(),
3757            exit_signal: Some(Signal::SIGCHLD),
3758            stack: None,
3759            child_tid: None,
3760            parent_tid: None,
3761            tls: None,
3762        };
3763        // SAFETY: fork simple (pas de `CLONE_VM`/namespace, pas de pile partagée) ;
3764        // aucune capability requise pour le fork lui-même.
3765        match unsafe { clone3(&args) }.expect("clone3 doit réussir") {
3766            CloneResult::Child => {
3767                // L'enfant acquiert le verrou exclusif (réussit : aucune contention
3768                // initiale), signale le parent, attend le feu vert, puis sort SANS
3769                // flush (sa couverture n'est pas requise ici — `exit_group` = `_exit`).
3770                let acquired = lock(fd.as_fd(), &write_lock).is_ok();
3771                let _ = write(locked_w.as_fd(), &[u8::from(acquired)]);
3772                let mut go = [0_u8; 1];
3773                let _ = read(release_r.as_fd(), &mut go);
3774                exit_group(i32::from(!acquired));
3775            }
3776            CloneResult::Parent { child_pid, .. } => {
3777                // Attend que l'enfant détienne le verrou.
3778                let mut signal = [0_u8; 1];
3779                assert_eq!(
3780                    read(locked_r.as_fd(), &mut signal).expect("signal locked"),
3781                    1
3782                );
3783                assert_eq!(signal[0], 1, "l'enfant a bien acquis le verrou exclusif");
3784                // ── CIBLE : contention → `Ok(false)` ──
3785                assert_eq!(
3786                    try_lock(fd.as_fd(), &write_lock),
3787                    Ok(false),
3788                    "verrou tenu par un autre processus → Ok(false)"
3789                );
3790                // Libère l'enfant et le récolte.
3791                let _ = write(release_w.as_fd(), &[1_u8]);
3792                let status = waitid(WaitTarget::Pid(child_pid), WaitOptions::EXITED)
3793                    .expect("waitid")
3794                    .expect("événement EXITED");
3795                assert!(
3796                    matches!(status.event, WaitEvent::Exited { code: 0 }),
3797                    "enfant sorti proprement : {:?}",
3798                    status.event
3799                );
3800            }
3801        }
3802        let _ = unlinkat(DirFd::Cwd, path, 0);
3803    }
3804
3805    #[test]
3806    fn try_lock_ebadf() {
3807        let invalid = invalid_fd();
3808        let lock = FileLock {
3809            lock_type: LockType::Write,
3810            whence: SeekWhence::Set,
3811            start: 0,
3812            length: 0,
3813            pid: 0,
3814        };
3815        assert_eq!(try_lock(invalid, &lock).unwrap_err(), Errno::EBADF);
3816    }
3817
3818    #[test]
3819    fn lock_ebadf() {
3820        let invalid = invalid_fd();
3821        let unlock = FileLock {
3822            lock_type: LockType::Unlock,
3823            whence: SeekWhence::Set,
3824            start: 0,
3825            length: 0,
3826            pid: 0,
3827        };
3828        assert_eq!(lock(invalid, &unlock).unwrap_err(), Errno::EBADF);
3829    }
3830
3831    // ── add_seals / get_seals ─────────────────────────────────────────────
3832
3833    #[test]
3834    fn seals_add_and_get_roundtrip() {
3835        // memfd_create with ALLOW_SEALING (memfd_create = x86_64:319, aarch64:279)
3836        #[cfg(target_arch = "x86_64")]
3837        let nr_memfd: i64 = 319;
3838        #[cfg(target_arch = "aarch64")]
3839        let nr_memfd: i64 = 279;
3840        let name = c"test-seals";
3841        // MFD_CLOEXEC | MFD_ALLOW_SEALING = 1 | 2 = 3
3842        // SAFETY: memfd_create via `syscall2`. `name` est une chaîne C statique
3843        // valide et NUL-terminée (lue par le kernel) ; retourne un FD ≥ 0 ou un errno.
3844        let ret = unsafe { syscall2(nr_memfd, name.as_ptr() as u64, 3) };
3845        if ret < 0 {
3846            return; // ENOSYS si indisponible
3847        }
3848        #[allow(clippy::cast_possible_truncation)]
3849        // SAFETY: `ret >= 0` ici, donc c'est un FD fraîchement créé par memfd_create,
3850        // non possédé ailleurs : on en prend la propriété exclusive.
3851        let fd = unsafe { OwnedFd::from_raw_fd(ret as i32) };
3852
3853        // Au départ, aucun sceau.
3854        let initial = get_seals(fd.as_fd()).expect("get_seals initial");
3855        assert!(initial.is_empty());
3856
3857        // Ajoute SEAL_SHRINK + SEAL_GROW.
3858        add_seals(fd.as_fd(), Seals::SHRINK | Seals::GROW).expect("add_seals");
3859        let after = get_seals(fd.as_fd()).expect("get_seals après");
3860        assert!(after.contains(Seals::SHRINK));
3861        assert!(after.contains(Seals::GROW));
3862    }
3863
3864    #[test]
3865    fn add_seals_ebadf() {
3866        let invalid = invalid_fd();
3867        assert_eq!(add_seals(invalid, Seals::SHRINK).unwrap_err(), Errno::EBADF);
3868    }
3869
3870    #[test]
3871    fn get_seals_ebadf() {
3872        let invalid = invalid_fd();
3873        assert_eq!(get_seals(invalid).unwrap_err(), Errno::EBADF);
3874    }
3875
3876    // ── name_to_handle_at ─────────────────────────────────────────────────
3877
3878    #[test]
3879    fn name_to_handle_at_ok_or_eopnotsupp() {
3880        // Sur la plupart des FS, name_to_handle_at réussit avec CAP_DAC_READ_SEARCH
3881        // OU retourne EOPNOTSUPP si le FS ne le supporte pas.
3882        // /tmp est souvent sur tmpfs qui le supporte.
3883        match name_to_handle_at(DirFd::Cwd, c"/tmp", NameToHandleFlags::empty()) {
3884            Ok(h) => {
3885                // Le handle est valide ; handle_type doit être un entier connu.
3886                assert!(!h.handle_bytes.is_empty() || h.handle_type != 0 || h.mount_id != 0);
3887            }
3888            Err(EOPNOTSUPP) => {
3889                // FS ne supporte pas les handles : acceptable.
3890                // (ENOTSUP == EOPNOTSUPP == 95 sur Linux : un seul bras suffit.)
3891            }
3892            Err(e) => panic!("name_to_handle_at inattendu : {e:?}"),
3893        }
3894    }
3895
3896    #[test]
3897    fn name_to_handle_at_enoent() {
3898        let err = name_to_handle_at(
3899            DirFd::Cwd,
3900            c"/air_no_such_handle_42",
3901            NameToHandleFlags::empty(),
3902        )
3903        .unwrap_err();
3904        assert_eq!(err, Errno::ENOENT);
3905    }
3906
3907    // ── copy_file_range avec Some offsets ─────────────────────────────────
3908
3909    #[test]
3910    fn copy_file_range_with_explicit_offsets() {
3911        let src = open_memfd();
3912        write(src.as_fd(), b"abcdefgh").expect("write src");
3913
3914        let dst = open_memfd();
3915        ftruncate(dst.as_fd(), 16).expect("ftruncate dst");
3916
3917        let mut offset_in: u64 = 2;
3918        let mut offset_out: u64 = 4;
3919        let n = copy_file_range(
3920            src.as_fd(),
3921            Some(&mut offset_in),
3922            dst.as_fd(),
3923            Some(&mut offset_out),
3924            4,
3925            0,
3926        )
3927        .expect("copy_file_range avec offsets");
3928        assert_eq!(n, 4);
3929        // offset_in avancé de 4, offset_out avancé de 4.
3930        assert_eq!(offset_in, 6);
3931        assert_eq!(offset_out, 8);
3932        // Vérifie que "cdef" est à la position 4 dans dst.
3933        lseek(dst.as_fd(), 4, SeekWhence::Set).expect("lseek dst");
3934        let mut buffer = [0u8; 4];
3935        let n2 = read(dst.as_fd(), &mut buffer).expect("read dst");
3936        assert_eq!(n2, 4);
3937        assert_eq!(&buffer, b"cdef");
3938    }
3939
3940    // ── unlinkat error path ───────────────────────────────────────────────
3941
3942    #[test]
3943    fn unlinkat_enoent_on_missing() {
3944        let err = unlinkat(DirFd::Cwd, c"/air_no_such_unlinkat_42", 0).unwrap_err();
3945        assert_eq!(err, Errno::ENOENT);
3946    }
3947
3948    // ── openat2 error path ───────────────────────────────────────────────
3949
3950    #[test]
3951    fn openat2_enoent_on_missing() {
3952        use air_sys_types::fs::{OpenHow, ResolveFlags};
3953        let how = OpenHow {
3954            flags: OpenFlags::RDONLY,
3955            mode: 0,
3956            resolve: ResolveFlags::empty(),
3957        };
3958        let err = openat2(DirFd::Cwd, c"/air_no_such_openat2_42", how).unwrap_err();
3959        assert_eq!(err, Errno::ENOENT);
3960    }
3961
3962    // ── ftruncate error path ──────────────────────────────────────────────
3963
3964    #[test]
3965    fn ftruncate_ebadf() {
3966        let invalid = invalid_fd();
3967        assert_eq!(ftruncate(invalid, 0).unwrap_err(), Errno::EBADF);
3968    }
3969
3970    // ── fsync error path ──────────────────────────────────────────────────
3971
3972    #[test]
3973    fn fsync_ebadf() {
3974        let invalid = invalid_fd();
3975        assert_eq!(fsync(invalid).unwrap_err(), Errno::EBADF);
3976    }
3977
3978    // ── statfs error path ─────────────────────────────────────────────────
3979
3980    #[test]
3981    fn statfs_enoent_on_missing() {
3982        let err = statfs(c"/air_no_such_path_statfs_42").unwrap_err();
3983        assert_eq!(err, Errno::ENOENT);
3984    }
3985
3986    // ── fstatfs error path ────────────────────────────────────────────────
3987
3988    #[test]
3989    fn fstatfs_ebadf() {
3990        let invalid = invalid_fd();
3991        assert_eq!(fstatfs(invalid).unwrap_err(), Errno::EBADF);
3992    }
3993
3994    // ── dup_fd error path ─────────────────────────────────────────────────
3995
3996    #[test]
3997    fn dup_fd_ebadf() {
3998        let invalid = invalid_fd();
3999        assert_eq!(dup_fd(invalid, 0).unwrap_err(), Errno::EBADF);
4000    }
4001
4002    // ── parse_dirents : couverture de tous les DirEntryType ───────────────
4003
4004    /// Construit un buffer `linux_dirent64` minimal pour le type donné.
4005    fn make_dirent_buf(file_type_byte: u8) -> Vec<u8> {
4006        // struct linux_dirent64 :
4007        //   ino:    u64 (8 bytes)  [0..8]
4008        //   off:    i64 (8 bytes)  [8..16]
4009        //   reclen: u16 (2 bytes)  [16..18]
4010        //   type:   u8  (1 byte)   [18]
4011        //   name:   b"x\0" (2 bytes) [19..21]
4012        // Total reclen = 21, padded to 24 for alignment.
4013        let reclen: u16 = 24;
4014        let mut buffer = vec![0u8; 24];
4015        buffer[0..8].copy_from_slice(&1u64.to_ne_bytes()); // ino = 1
4016        buffer[8..16].copy_from_slice(&1i64.to_ne_bytes()); // offset = 1
4017        buffer[16..18].copy_from_slice(&reclen.to_ne_bytes()); // reclen
4018        buffer[18] = file_type_byte; // type
4019        buffer[19] = b'x'; // name
4020        buffer[20] = 0; // NUL
4021        buffer
4022    }
4023
4024    #[test]
4025    fn parse_dirents_all_types() {
4026        // Type bytes: Fifo=1, Char=2, Dir=4, Block=6, Reg=8, Symlink=10, Socket=12, Unknown=0
4027        for (byte, expected) in [
4028            (1u8, DirEntryType::Fifo),
4029            (2, DirEntryType::Character),
4030            (4, DirEntryType::Directory),
4031            (6, DirEntryType::Block),
4032            (8, DirEntryType::Regular),
4033            (10, DirEntryType::Symlink),
4034            (12, DirEntryType::Socket),
4035            (0, DirEntryType::Unknown),
4036            (255, DirEntryType::Unknown), // autre valeur inconnue
4037        ] {
4038            let buffer = make_dirent_buf(byte);
4039            let entries: Vec<_> = parse_dirents(&buffer).collect();
4040            assert_eq!(entries.len(), 1, "type_byte={byte}");
4041            assert_eq!(entries[0].file_type, expected, "type_byte={byte}");
4042        }
4043    }
4044
4045    #[test]
4046    fn parse_dirents_empty_buf() {
4047        let entries: Vec<_> = parse_dirents(&[]).collect();
4048        assert!(entries.is_empty());
4049    }
4050
4051    #[test]
4052    fn parse_dirents_truncated_header() {
4053        // Buffer de moins de 19 octets : retourne None.
4054        let buffer = vec![0u8; 10];
4055        let entries: Vec<_> = parse_dirents(&buffer).collect();
4056        assert!(entries.is_empty());
4057    }
4058
4059    #[test]
4060    fn parse_dirents_reclen_zero() {
4061        // reclen=0 doit stopper l'itération sans paniquer.
4062        let mut buffer = vec![0u8; 24];
4063        buffer[0..8].copy_from_slice(&1u64.to_ne_bytes());
4064        buffer[8..16].copy_from_slice(&1i64.to_ne_bytes());
4065        buffer[16..18].copy_from_slice(&0u16.to_ne_bytes()); // reclen = 0
4066        buffer[18] = 8;
4067        buffer[19] = b'x';
4068        buffer[20] = 0;
4069        let entries: Vec<_> = parse_dirents(&buffer).collect();
4070        assert!(entries.is_empty());
4071    }
4072
4073    #[test]
4074    fn parse_dirents_name_without_nul() {
4075        // Nom sans NUL terminal : unwrap_or(name_region.len()) couvre ce cas.
4076        let reclen: u16 = 24;
4077        let mut buffer = vec![0u8; 24];
4078        buffer[0..8].copy_from_slice(&1u64.to_ne_bytes());
4079        buffer[8..16].copy_from_slice(&1i64.to_ne_bytes());
4080        buffer[16..18].copy_from_slice(&reclen.to_ne_bytes());
4081        buffer[18] = 8; // Regular
4082        // Remplir le nom avec des octets non-NUL.
4083        for b in buffer[19..24].iter_mut() {
4084            *b = b'a';
4085        }
4086        let entries: Vec<_> = parse_dirents(&buffer).collect();
4087        assert_eq!(entries.len(), 1);
4088    }
4089}