From 301b479fa6375d5a1c250ee5a53572816dcc6fb6 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Tue, 13 Feb 2024 11:30:06 +0000 Subject: [PATCH] eio_posix: use directory FDs instead of realpath realpath was an old hack from the libuv days. --- lib_eio/unix/eio_unix.mli | 1 + lib_eio/unix/fd.ml | 5 + lib_eio/unix/fd.mli | 3 + lib_eio/unix/private.ml | 5 +- lib_eio_posix/eio_posix_stubs.c | 11 ++ lib_eio_posix/err.ml | 4 +- lib_eio_posix/fs.ml | 150 ++++----------- lib_eio_posix/low_level.ml | 292 ++++++++++++++++++++++++----- lib_eio_posix/low_level.mli | 28 ++- lib_eio_posix/net.ml | 2 +- lib_eio_posix/path.ml | 81 ++++++++ lib_eio_posix/path.mli | 26 +++ lib_eio_posix/primitives.h | 1 + lib_eio_posix/process.ml | 7 +- lib_eio_posix/test/dune | 6 + lib_eio_posix/test/open_beneath.ml | 92 +++++++++ lib_eio_posix/test/path.md | 58 ++++++ lib_eio_posix/test/spawn.md | 2 +- tests/fs.md | 11 +- 19 files changed, 604 insertions(+), 181 deletions(-) create mode 100644 lib_eio_posix/path.ml create mode 100644 lib_eio_posix/path.mli create mode 100644 lib_eio_posix/test/open_beneath.ml create mode 100644 lib_eio_posix/test/path.md diff --git a/lib_eio/unix/eio_unix.mli b/lib_eio/unix/eio_unix.mli index fd450a3da..10ba6d48a 100644 --- a/lib_eio/unix/eio_unix.mli +++ b/lib_eio/unix/eio_unix.mli @@ -103,6 +103,7 @@ module Private : sig module Thread_pool = Thread_pool val read_link : Fd.t option -> string -> string + val read_link_unix : Unix.file_descr option -> string -> string end module Pi = Pi diff --git a/lib_eio/unix/fd.ml b/lib_eio/unix/fd.ml index d0766d607..56c9bd5c7 100644 --- a/lib_eio/unix/fd.ml +++ b/lib_eio/unix/fd.ml @@ -87,6 +87,11 @@ let rec use_exn_list op xs k = use_exn_list op xs @@ fun xs -> k (x :: xs) +let use_exn_opt op x f = + match x with + | None -> f None + | Some x -> use_exn op x (fun x -> f (Some x)) + let stdin = of_unix_no_hook Unix.stdin let stdout = of_unix_no_hook Unix.stdout let stderr= of_unix_no_hook Unix.stderr diff --git a/lib_eio/unix/fd.mli b/lib_eio/unix/fd.mli index cef77b833..f12e6c03e 100644 --- a/lib_eio/unix/fd.mli +++ b/lib_eio/unix/fd.mli @@ -35,6 +35,9 @@ val use_exn : string -> t -> (Unix.file_descr -> 'a) -> 'a val use_exn_list : string -> t list -> (Unix.file_descr list -> 'a) -> 'a (** [use_exn_list op fds fn] calls {!use_exn} on each FD in [fds], calling [fn wrapped_fds] on the results. *) +val use_exn_opt : string -> t option -> (Unix.file_descr option -> 'a) -> 'a +(** [use_exn_opt op fd fn] is like {!use_exn}, but if [fd = None] then it just calls [fn None]. *) + (** {2 Closing} *) val close : t -> unit diff --git a/lib_eio/unix/private.ml b/lib_eio/unix/private.ml index 5964af7ba..961990766 100644 --- a/lib_eio/unix/private.ml +++ b/lib_eio/unix/private.ml @@ -20,11 +20,10 @@ module Thread_pool = Thread_pool external eio_readlinkat : Unix.file_descr -> string -> Cstruct.t -> int = "eio_unix_readlinkat" -let read_link fd path = +let read_link_unix fd path = match fd with | None -> Unix.readlink path | Some fd -> - Fd.use_exn "readlink" fd @@ fun fd -> let rec aux size = let buf = Cstruct.create_unsafe size in let len = eio_readlinkat fd path buf in @@ -32,3 +31,5 @@ let read_link fd path = else aux (size * 4) in aux 1024 + +let read_link fd path = Fd.use_exn_opt "readlink" fd (fun fd -> read_link_unix fd path) diff --git a/lib_eio_posix/eio_posix_stubs.c b/lib_eio_posix/eio_posix_stubs.c index 21b862805..199b714c3 100644 --- a/lib_eio_posix/eio_posix_stubs.c +++ b/lib_eio_posix/eio_posix_stubs.c @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -527,3 +528,13 @@ CAMLprim value caml_eio_posix_recv_msg(value v_fd, value v_max_fds, value v_bufs CAMLreturn(v_result); } + +CAMLprim value caml_eio_posix_fdopendir(value v_fd) { + DIR *d = fdopendir(Int_val(v_fd)); + if (!d) + caml_uerror("fdopendir", Nothing); + + value v_result = caml_alloc_small(1, Abstract_tag); + DIR_Val(v_result) = d; + return v_result; +} diff --git a/lib_eio_posix/err.ml b/lib_eio_posix/err.ml index f813a06c1..00017155f 100644 --- a/lib_eio_posix/err.ml +++ b/lib_eio_posix/err.ml @@ -1,5 +1,5 @@ type Eio.Exn.Backend.t += - | Outside_sandbox of string * string + | Outside_sandbox of string | Absolute_path | Invalid_leaf of string @@ -7,7 +7,7 @@ let unclassified_error e = Eio.Exn.create (Eio.Exn.X e) let () = Eio.Exn.Backend.register_pp (fun f -> function - | Outside_sandbox (path, dir) -> Fmt.pf f "Outside_sandbox (%S, %S)" path dir; true + | Outside_sandbox path -> Fmt.pf f "Outside_sandbox (%S)" path; true | Absolute_path -> Fmt.pf f "Absolute_path"; true | Invalid_leaf x -> Fmt.pf f "Invalid_leaf %S" x; true | _ -> false diff --git a/lib_eio_posix/fs.ml b/lib_eio_posix/fs.ml index e41baba2a..a5723ed50 100644 --- a/lib_eio_posix/fs.ml +++ b/lib_eio_posix/fs.ml @@ -16,84 +16,44 @@ (* This module provides (optional) sandboxing, allowing operations to be restricted to a subtree. - For now, sandboxed directories use realpath and [O_NOFOLLOW], which is probably quite slow, - and requires duplicating a load of path lookup logic from the kernel. - It might be better to hold a directory FD rather than a path. - On FreeBSD we could use O_RESOLVE_BENEATH and let the OS handle everything for us. - On other systems we would have to resolve one path component at a time. *) + On FreeBSD we use O_RESOLVE_BENEATH and let the OS handle everything for us. + On other systems we resolve one path component at a time. *) open Eio.Std module Fd = Eio_unix.Fd +(* When renaming, we get a plain [Eio.Fs.dir]. We need extra access to check + that the new location is within its sandbox. *) +type (_, _, _) Eio.Resource.pi += Posix_dir : ('t, 't -> Low_level.dir_fd, [> `Posix_dir]) Eio.Resource.pi + +let as_posix_dir (Eio.Resource.T (t, ops)) = + match Eio.Resource.get_opt ops Posix_dir with + | None -> None + | Some fn -> Some (fn t) + module rec Dir : sig include Eio.Fs.Pi.DIR - val v : label:string -> sandbox:bool -> string -> t - - val resolve : t -> string -> string - (** [resolve t path] returns the real path that should be used to access [path]. - For sandboxes, this is [realpath path] (and it checks that it is within the sandbox). - For unrestricted access, this returns [path] unchanged. - @raise Eio.Fs.Permission_denied if sandboxed and [path] is outside of [dir_path]. *) + val v : label:string -> path:string -> Low_level.dir_fd -> t - val with_parent_dir : t -> string -> (Fd.t option -> string -> 'a) -> 'a - (** [with_parent_dir t path fn] runs [fn dir_fd rel_path], - where [rel_path] accessed relative to [dir_fd] gives access to [path]. - For unrestricted access, this just runs [fn None path]. - For sandboxes, it opens the parent of [path] as [dir_fd] and runs [fn (Some dir_fd) (basename path)]. *) + val fd : t -> Low_level.dir_fd end = struct type t = { + fd : Low_level.dir_fd; dir_path : string; - sandbox : bool; label : string; - mutable closed : bool; } - let resolve t path = - if t.sandbox then ( - if t.closed then Fmt.invalid_arg "Attempt to use closed directory %S" t.dir_path; - if Filename.is_relative path then ( - let dir_path = Err.run Low_level.realpath t.dir_path in - let full = Err.run Low_level.realpath (Filename.concat dir_path path) in - let prefix_len = String.length dir_path + 1 in - if String.length full >= prefix_len && String.sub full 0 prefix_len = dir_path ^ Filename.dir_sep then - full - else if full = dir_path then - full - else - raise @@ Eio.Fs.err (Permission_denied (Err.Outside_sandbox (full, dir_path))) - ) else ( - raise @@ Eio.Fs.err (Permission_denied Err.Absolute_path) - ) - ) else path - - let with_parent_dir t path fn = - if t.sandbox then ( - if t.closed then Fmt.invalid_arg "Attempt to use closed directory %S" t.dir_path; - let dir, leaf = Filename.dirname path, Filename.basename path in - let dir, leaf = - if leaf = ".." then path, "." - else dir, leaf - in - let dir = resolve t dir in - Switch.run ~name:"with_parent_dir" @@ fun sw -> - let dirfd = Low_level.openat ~sw ~mode:0 dir Low_level.Open_flags.(directory + rdonly + nofollow) in - fn (Some dirfd) leaf - ) else fn None path - - let v ~label ~sandbox dir_path = { dir_path; sandbox; label; closed = false } + let fd t = t.fd - (* Sandboxes use [O_NOFOLLOW] when opening files ([resolve] already removed any symlinks). - This avoids a race where symlink might be added after [realpath] returns. *) - let opt_nofollow t = - if t.sandbox then Low_level.Open_flags.nofollow else Low_level.Open_flags.empty + let v ~label ~path:dir_path fd = { fd; dir_path; label } let open_in t ~sw path = - let fd = Err.run (Low_level.openat ~mode:0 ~sw (resolve t path)) Low_level.Open_flags.(opt_nofollow t + rdonly) in + let fd = Err.run (Low_level.openat ~mode:0 ~sw t.fd path) Low_level.Open_flags.rdonly in (Flow.of_fd fd :> Eio.File.ro_ty Eio.Resource.t) - let rec open_out t ~sw ~append ~create path = + let open_out t ~sw ~append ~create path = let mode, flags = match create with | `Never -> 0, Low_level.Open_flags.empty @@ -102,73 +62,44 @@ end = struct | `Exclusive perm -> perm, Low_level.Open_flags.(creat + excl) in let flags = if append then Low_level.Open_flags.(flags + append) else flags in - let flags = Low_level.Open_flags.(flags + rdwr + opt_nofollow t) in - match - with_parent_dir t path @@ fun dirfd path -> - Low_level.openat ?dirfd ~sw ~mode path flags - with + let flags = Low_level.Open_flags.(flags + rdwr) in + match Low_level.openat ~sw ~mode t.fd path flags with | fd -> (Flow.of_fd fd :> Eio.File.rw_ty r) - | exception Unix.Unix_error (ELOOP, _, _) -> - (* The leaf was a symlink (or we're unconfined and the main path changed, but ignore that). - A leaf symlink might be OK, but we need to check it's still in the sandbox. - todo: possibly we should limit the number of redirections here, like the kernel does. *) - let target = Unix.readlink path in - let full_target = - if Filename.is_relative target then - Filename.concat (Filename.dirname path) target - else target - in - open_out t ~sw ~append ~create full_target | exception Unix.Unix_error (code, name, arg) -> raise (Err.wrap code name arg) let mkdir t ~perm path = - with_parent_dir t path @@ fun dirfd path -> - Err.run (Low_level.mkdir ?dirfd ~mode:perm) path + Err.run (Low_level.mkdir ~mode:perm t.fd) path let unlink t path = - with_parent_dir t path @@ fun dirfd path -> - Err.run (Low_level.unlink ?dirfd ~dir:false) path + Err.run (Low_level.unlink ~dir:false t.fd) path let rmdir t path = - with_parent_dir t path @@ fun dirfd path -> - Err.run (Low_level.unlink ?dirfd ~dir:true) path + Err.run (Low_level.unlink ~dir:true t.fd) path let stat t ~follow path = let buf = Low_level.create_stat () in - if follow then ( - Err.run (Low_level.fstatat ~buf ~follow:true) (resolve t path); - ) else ( - with_parent_dir t path @@ fun dirfd path -> - Err.run (Low_level.fstatat ~buf ?dirfd ~follow:false) path; - ); + Err.run (Low_level.fstatat ~buf ~follow t.fd) path; Flow.eio_of_stat buf let read_dir t path = - (* todo: need fdopendir here to avoid races *) - let path = resolve t path in - Err.run Low_level.readdir path + Err.run (Low_level.readdir t.fd) path |> Array.to_list let read_link t path = - with_parent_dir t path @@ fun dirfd path -> - Err.run (Low_level.read_link ?dirfd) path + Err.run (Low_level.read_link t.fd) path let rename t old_path new_dir new_path = - match Handler.as_posix_dir new_dir with + match as_posix_dir new_dir with | None -> invalid_arg "Target is not an eio_posix directory!" - | Some new_dir -> - with_parent_dir t old_path @@ fun old_dir old_path -> - with_parent_dir new_dir new_path @@ fun new_dir new_path -> - Err.run (Low_level.rename ?old_dir old_path ?new_dir) new_path - - let close t = t.closed <- true + | Some new_dir -> Err.run (Low_level.rename t.fd old_path new_dir) new_path let open_dir t ~sw path = - Switch.check sw; + let flags = Low_level.Open_flags.(rdonly + directory +? path) in + let fd = Err.run (Low_level.openat ~sw ~mode:0 t.fd path) flags in let label = Filename.basename path in - let d = v ~label (resolve t path) ~sandbox:true in - Switch.on_release sw (fun () -> close d); + let full_path = if Filename.is_relative path then Filename.concat t.dir_path path else path in + let d = v ~label ~path:full_path (Fd fd) in Eio.Resource.T (d, Handler.v) let pp f t = Fmt.string f (String.escaped t.label) @@ -190,24 +121,13 @@ end = struct end and Handler : sig val v : (Dir.t, [`Dir | `Close]) Eio.Resource.handler - - val as_posix_dir : [> `Dir] r -> Dir.t option end = struct - (* When renaming, we get a plain [Eio.Fs.dir]. We need extra access to check - that the new location is within its sandbox. *) - type (_, _, _) Eio.Resource.pi += Posix_dir : ('t, 't -> Dir.t, [> `Posix_dir]) Eio.Resource.pi - - let as_posix_dir (Eio.Resource.T (t, ops)) = - match Eio.Resource.get_opt ops Posix_dir with - | None -> None - | Some fn -> Some (fn t) - let v = Eio.Resource.handler [ H (Eio.Fs.Pi.Dir, (module Dir)); - H (Posix_dir, Fun.id); + H (Posix_dir, Dir.fd); ] end (* Full access to the filesystem. *) -let fs = Eio.Resource.T (Dir.v ~label:"fs" ~sandbox:false ".", Handler.v) -let cwd = Eio.Resource.T (Dir.v ~label:"cwd" ~sandbox:true ".", Handler.v) +let fs = Eio.Resource.T (Dir.v ~label:"fs" ~path:"." Fs, Handler.v) +let cwd = Eio.Resource.T (Dir.v ~label:"cwd" ~path:"." Cwd, Handler.v) diff --git a/lib_eio_posix/low_level.ml b/lib_eio_posix/low_level.ml index ec237d9ce..9c9197c8b 100644 --- a/lib_eio_posix/low_level.ml +++ b/lib_eio_posix/low_level.ml @@ -15,6 +15,11 @@ module Fd = Eio_unix.Fd module Trace = Eio.Private.Trace module Fiber_context = Eio.Private.Fiber_context +type dir_fd = + | Fd of Fd.t + | Cwd (* Confined to "." *) + | Fs (* Unconfined "."; also allows absolute paths *) + let in_worker_thread label = Eio_unix.run_in_systhread ~label let await_readable op fd = @@ -130,19 +135,6 @@ let read_entries h = in aux [] -let readdir path = - in_worker_thread "readdir" @@ fun () -> - let h = Unix.opendir path in - match read_entries h with - | r -> Unix.closedir h; r - | exception ex -> - let bt = Printexc.get_raw_backtrace () in - Unix.closedir h; Printexc.raise_with_backtrace ex bt - -let read_link ?dirfd path = - in_worker_thread "read_link" @@ fun () -> - Eio_unix.Private.read_link dirfd path - external eio_readv : Unix.file_descr -> Cstruct.t array -> int = "caml_eio_posix_readv" external eio_writev : Unix.file_descr -> Cstruct.t array -> int = "caml_eio_posix_writev" @@ -191,59 +183,242 @@ module Open_flags = struct let empty = 0 let ( + ) = ( lor ) + + let ( +? ) x = function + | None -> x + | Some y -> x + y end -let rec with_dirfd op dirfd fn = - match dirfd with - | None -> fn (Obj.magic Config.at_fdcwd : Unix.file_descr) - | Some dirfd -> Fd.use_exn op dirfd fn - | exception Unix.Unix_error(Unix.EINTR, _, "") -> with_dirfd op dirfd fn +let at_fdcwd : Unix.file_descr = Obj.magic Config.at_fdcwd external eio_openat : Unix.file_descr -> string -> Open_flags.t -> int -> Unix.file_descr = "caml_eio_posix_openat" +let eio_openat fd path flags mode = + let fd = Option.value fd ~default:at_fdcwd in + eio_openat fd path Open_flags.(flags + cloexec) mode + +module Resolve = struct + (** Resolve a path one step at a time. + This simulates how the kernel does path resolution using O_RESOLVE_BENEATH, + for kernels that don't support it. + + These functions should be called from a worker sys-thread, since lookups can + be slow, especially on network file-systems and user-space mounts. + + When doing lookups, we cannot ask the kernel to follow ".." links, since the + directory might get moved during the operation. e.g. + + Process 1: openat [/tmp/sandbox/] "foo/../bar" + Process 2: mv /tmp/sandbox/foo /var/foo + + Process 1 starts by opening "foo", then process 2 moves it, then process 1 + follows the "../bar", opening /var/bar, to which it should not have access. + Instead, we keep a stack of opened directories and pop one when we see "..". + todo: possibly we should check we have search permission on ".." before + doing this. + *) + + type dir_stack = + | Base of Unix.file_descr option (* Base dir from user (do not close). None if cwd *) + | Tmp of Unix.file_descr * dir_stack (* Will be closed if in [dir_stack] at end. *) + + type state = { + mutable dir_stack : dir_stack; (* Directories already opened, for ".." *) + mutable max_follows : int; (* Max symlinks before reporting ELOOP *) + } -let openat ?dirfd ~sw ~mode path flags = - with_dirfd "openat" dirfd @@ fun dirfd -> - Switch.check sw; - in_worker_thread "openat" (fun () -> eio_openat dirfd path Open_flags.(flags + cloexec + nonblock) mode) - |> Fd.of_unix ~sw ~blocking:false ~close_unix:true + let current_dir state = + match state.dir_stack with + | Base b -> b + | Tmp (x, _) -> Some x + + let parse_rel s = + match Path.parse s with + | Relative r -> r + | Absolute _ -> raise @@ Eio.Fs.err (Eio.Fs.Permission_denied (Err.Absolute_path)) + + let decr_max_follows state x = + if state.max_follows > 0 then + state.max_follows <- state.max_follows - 1 + else + raise (Unix.Unix_error (ELOOP, "resolve", x)) + + (* Fallback for systems without O_RESOLVE_BENEATH: *) + let rec resolve state (path : Path.Rel.t) = + (* traceln "Consider %a" Path.Rel.dump path; *) + match path with + | Leaf { basename; trailing_slash } -> if trailing_slash then basename ^ "/" else basename + | Self -> "." + | Parent xs -> + begin match state.dir_stack with + | Base _ -> + raise @@ Eio.Fs.err (Permission_denied (Err.Outside_sandbox (Path.Rel.to_string path))) + | Tmp (p, ps) -> + Unix.close p; + state.dir_stack <- ps; + resolve state xs + end + | Child (x, xs) -> + let base = current_dir state in + match eio_openat base x Open_flags.(nofollow + directory +? path) 0 with + | new_base -> + state.dir_stack <- Tmp (new_base, state.dir_stack); + resolve state xs + | exception (Unix.Unix_error ((ENOTDIR | EMLINK | EUNKNOWNERR _), _, _) as e) -> + match Eio_unix.Private.read_link_unix base x with + | target -> + decr_max_follows state x; + resolve state (Path.Rel.concat (parse_rel target) xs) + | exception Unix.Unix_error _ -> raise e (* Not a symlink; report original error instead *) + + let close_tmp state = + let rec aux = function + | Base _ -> () + | Tmp (x, xs) -> Unix.close x; aux xs + in + aux state.dir_stack + + let with_state base fn = + (* [max_follows] matches Linux's value; see path_resolution(7) *) + let state = { dir_stack = Base base; max_follows = 40 } in + match fn state with + | x -> close_tmp state; x + | exception ex -> + let bt = Printexc.get_raw_backtrace () in + close_tmp state; + Printexc.raise_with_backtrace ex bt + + let open_beneath_fallback ?dirfd:base ~sw ~mode path flags = + let path = parse_rel path in + with_state base @@ fun state -> + (* Resolve the parent, then try to open the last component with [flags + nofollow]. + If it's a symlink, retry with the target. *) + let rec aux leaf = + let base = current_dir state in + match eio_openat base leaf Open_flags.(flags + nofollow) mode with + | fd -> Fd.of_unix fd ~sw ~blocking:false ~close_unix:true + | exception (Unix.Unix_error ((ELOOP | ENOTDIR | EMLINK | EUNKNOWNERR _), _, _) as e) -> + (* Note: Linux uses ELOOP or ENOTDIR. FreeBSD used EMLINK. NetBSD uses EFTYPE. *) + match Eio_unix.Private.read_link_unix base leaf with + | target -> + decr_max_follows state leaf; + aux (resolve state (parse_rel target)) + | exception Unix.Unix_error _ -> raise e + in + aux (resolve state path) + + (* Resolve until the last component and call [fn dir leaf]. + That returns [Error `Symlink] if [leaf] is a symlink, in + which case we read its target and continue. *) + let with_parent_loop ?dirfd:base path fn = + let path = parse_rel path in + with_state base @@ fun state -> + let rec aux leaf = + let base = current_dir state in + match fn base leaf with + | Ok x -> x + | Error (`Symlink e) -> + decr_max_follows state leaf; + match Eio_unix.Private.read_link_unix base leaf with + | target -> aux (resolve state (parse_rel target)) + | exception Unix.Unix_error _ when Option.is_some e -> raise (Option.get e) + in + aux (resolve state path) + + (* If confined, resolve until the last component and call [fn dir leaf]. + If unconfined, just call [fn None path]. + If you need to follow [leaf] if it turns out to be a symlink, + use [with_parent_loop] instead. *) + let with_parent op fd path fn = (* todo: use o_resolve_beneath if available *) + match fd with + | Fs -> fn None path + | Cwd -> with_parent_loop path (fun x y -> Ok (fn x y)) + | Fd dirfd -> + Fd.use_exn op dirfd @@ fun dirfd -> + with_parent_loop ~dirfd path (fun x y -> Ok (fn x y)) + + let open_unconfined ~sw ~mode dirfd path flags = + Fd.use_exn_opt "openat" dirfd @@ fun dirfd -> + eio_openat dirfd path Open_flags.(flags + nonblock) mode + |> Fd.of_unix ~sw ~blocking:false ~close_unix:true + + let open_beneath ?dirfd ~sw ~mode path flags = + match Open_flags.resolve_beneath with + | Some o_resolve_beneath -> + open_unconfined ~sw ~mode dirfd path Open_flags.(flags + o_resolve_beneath) + | None -> + Fd.use_exn_opt "open_beneath" dirfd @@ fun dirfd -> + open_beneath_fallback ?dirfd ~sw ~mode path flags +end + +let openat ~sw ~mode fd path flags = + let path = if path = "" then "." else path in + in_worker_thread "openat" @@ fun () -> + match fd with + | Fs -> Resolve.open_unconfined ~sw ~mode None path flags + | Cwd -> Resolve.open_beneath ~sw ~mode ?dirfd:None path flags + | Fd dirfd -> Resolve.open_beneath ~sw ~mode ~dirfd path flags + +external eio_fdopendir : Unix.file_descr -> Unix.dir_handle = "caml_eio_posix_fdopendir" + +let readdir dirfd path = + in_worker_thread "readdir" @@ fun () -> + let use h = + match read_entries h with + | r -> Unix.closedir h; r + | exception ex -> + let bt = Printexc.get_raw_backtrace () in + Unix.closedir h; + Printexc.raise_with_backtrace ex bt + in + let use_confined dirfd = + Resolve.with_parent_loop ?dirfd path @@ fun dirfd path -> + match eio_openat dirfd path Open_flags.(rdonly + directory + nofollow) 0 with + | fd -> Ok (use (eio_fdopendir fd)) + | exception (Unix.Unix_error ((ELOOP | ENOTDIR | EMLINK | EUNKNOWNERR _), _, _) as e) -> Error (`Symlink (Some e)) + in + match dirfd with + | Fs -> use (Unix.opendir path) + | Cwd -> use_confined None + | Fd dirfd -> + Fd.use_exn "readdir" dirfd @@ fun dirfd -> + use_confined (Some dirfd) external eio_mkdirat : Unix.file_descr -> string -> Unix.file_perm -> unit = "caml_eio_posix_mkdirat" -let mkdir ?dirfd ~mode path = - with_dirfd "mkdirat" dirfd @@ fun dirfd -> +let mkdir ~mode dirfd path = in_worker_thread "mkdir" @@ fun () -> + Resolve.with_parent "mkdir" dirfd path @@ fun dirfd path -> + let dirfd = Option.value dirfd ~default:at_fdcwd in eio_mkdirat dirfd path mode external eio_unlinkat : Unix.file_descr -> string -> bool -> unit = "caml_eio_posix_unlinkat" -let unlink ?dirfd ~dir path = - with_dirfd "unlink" dirfd @@ fun dirfd -> +let unlink ~dir dirfd path = in_worker_thread "unlink" @@ fun () -> + Resolve.with_parent "unlink" dirfd path @@ fun dirfd path -> + let dirfd = Option.value dirfd ~default:at_fdcwd in eio_unlinkat dirfd path dir external eio_renameat : Unix.file_descr -> string -> Unix.file_descr -> string -> unit = "caml_eio_posix_renameat" -let rename ?old_dir old_path ?new_dir new_path = - with_dirfd "rename-old" old_dir @@ fun old_dir -> - with_dirfd "rename-new" new_dir @@ fun new_dir -> +let rename old_dir old_path new_dir new_path = in_worker_thread "rename" @@ fun () -> + Resolve.with_parent "rename-old" old_dir old_path @@ fun old_dir old_path -> + Resolve.with_parent "rename-new" new_dir new_path @@ fun new_dir new_path -> + let old_dir = Option.value old_dir ~default:at_fdcwd in + let new_dir = Option.value new_dir ~default:at_fdcwd in eio_renameat old_dir old_path new_dir new_path +let read_link dirfd path = + in_worker_thread "read_link" @@ fun () -> + Resolve.with_parent "read_link" dirfd path @@ fun dirfd path -> + Eio_unix.Private.read_link_unix dirfd path + type stat external create_stat : unit -> stat = "caml_eio_posix_make_stat" external eio_fstatat : stat -> Unix.file_descr -> string -> int -> unit = "caml_eio_posix_fstatat" external eio_fstat : stat -> Unix.file_descr -> unit = "caml_eio_posix_fstat" -let fstat ~buf fd = - Fd.use_exn "fstat" fd @@ fun fd -> - eio_fstat buf fd - -let fstatat ~buf ?dirfd ~follow path = - in_worker_thread "fstat" @@ fun () -> - let flags = if follow then 0 else Config.at_symlink_nofollow in - with_dirfd "fstatat" dirfd @@ fun dirfd -> - eio_fstatat buf dirfd path flags - external blksize : stat -> (int64 [@unboxed]) = "ocaml_eio_posix_stat_blksize_bytes" "ocaml_eio_posix_stat_blksize_native" [@@noalloc] external nlink : stat -> (int64 [@unboxed]) = "ocaml_eio_posix_stat_nlink_bytes" "ocaml_eio_posix_stat_nlink_native" [@@noalloc] external uid : stat -> (int64 [@unboxed]) = "ocaml_eio_posix_stat_uid_bytes" "ocaml_eio_posix_stat_uid_native" [@@noalloc] @@ -264,6 +439,27 @@ external atime_nsec : stat -> int = "ocaml_eio_posix_stat_atime_nsec" [@@noalloc external ctime_nsec : stat -> int = "ocaml_eio_posix_stat_ctime_nsec" [@@noalloc] external mtime_nsec : stat -> int = "ocaml_eio_posix_stat_mtime_nsec" [@@noalloc] +let fstat ~buf fd = + Fd.use_exn "fstat" fd @@ fun fd -> + eio_fstat buf fd + +let fstatat_confined ~buf ~follow dirfd path = + Resolve.with_parent_loop ?dirfd path @@ fun dirfd path -> + let dirfd = Option.value dirfd ~default:at_fdcwd in + eio_fstatat buf dirfd path Config.at_symlink_nofollow; + if follow && kind buf = `Symbolic_link then Error (`Symlink None) else Ok () + +let fstatat ~buf ~follow dirfd path = + in_worker_thread "fstat" @@ fun () -> + match dirfd with + | Fs -> + let flags = if follow then 0 else Config.at_symlink_nofollow in + eio_fstatat buf at_fdcwd path flags + | Cwd -> fstatat_confined ~buf ~follow None path + | Fd dirfd -> + Fd.use_exn "fstat" dirfd @@ fun dirfd -> + fstatat_confined ~buf ~follow (Some dirfd) path + let lseek fd off cmd = Fd.use_exn "lseek" fd @@ fun fd -> let cmd = @@ -330,14 +526,14 @@ module Process = struct (* Wait for [pid] to exit and then resolve [exit_status] to its status. *) let reap t exit_status = Eio.Condition.loop_no_mutex Eio_unix.Process.sigchld (fun () -> - Mutex.lock t.lock; - match Unix.waitpid [WNOHANG] t.pid with - | 0, _ -> Mutex.unlock t.lock; None (* Not ready; wait for next SIGCHLD *) - | p, status -> - assert (p = t.pid); - Promise.resolve exit_status status; - Mutex.unlock t.lock; - Some () + Mutex.lock t.lock; + match Unix.waitpid [WNOHANG] t.pid with + | 0, _ -> Mutex.unlock t.lock; None (* Not ready; wait for next SIGCHLD *) + | p, status -> + assert (p = t.pid); + Promise.resolve exit_status status; + Mutex.unlock t.lock; + Some () ) let spawn ~sw actions = diff --git a/lib_eio_posix/low_level.mli b/lib_eio_posix/low_level.mli index 11716e0f0..a829ca705 100644 --- a/lib_eio_posix/low_level.mli +++ b/lib_eio_posix/low_level.mli @@ -14,6 +14,11 @@ open Eio.Std type fd := Eio_unix.Fd.t +type dir_fd = + | Fd of fd (** Confined to [fd]. *) + | Cwd (** Confined to "." *) + | Fs (** Unconfined "."; also allows absolute paths *) + val await_readable : string -> fd -> unit val await_writable : string -> fd -> unit @@ -44,7 +49,7 @@ type stat val create_stat : unit -> stat val fstat : buf:stat -> fd -> unit -val fstatat : buf:stat -> ?dirfd:fd -> follow:bool -> string -> unit +val fstatat : buf:stat -> follow:bool -> dir_fd -> string -> unit external blksize : stat -> (int64 [@unboxed]) = "ocaml_eio_posix_stat_blksize_bytes" "ocaml_eio_posix_stat_blksize_native" [@@noalloc] external nlink : stat -> (int64 [@unboxed]) = "ocaml_eio_posix_stat_nlink_bytes" "ocaml_eio_posix_stat_nlink_native" [@@noalloc] @@ -67,13 +72,13 @@ external ctime_nsec : stat -> int = "ocaml_eio_posix_stat_ctime_nsec" [@@noalloc external mtime_nsec : stat -> int = "ocaml_eio_posix_stat_mtime_nsec" [@@noalloc] val realpath : string -> string -val read_link : ?dirfd:fd -> string -> string +val read_link : dir_fd -> string -> string -val mkdir : ?dirfd:fd -> mode:int -> string -> unit -val unlink : ?dirfd:fd -> dir:bool -> string -> unit -val rename : ?old_dir:fd -> string -> ?new_dir:fd -> string -> unit +val mkdir : mode:int -> dir_fd -> string -> unit +val unlink : dir:bool -> dir_fd -> string -> unit +val rename : dir_fd -> string -> dir_fd -> string -> unit -val readdir : string -> string array +val readdir : dir_fd -> string -> string array val readv : fd -> Cstruct.t array -> int val writev : fd -> Cstruct.t array -> int @@ -103,9 +108,10 @@ module Open_flags : sig val empty : t val ( + ) : t -> t -> t + val ( +? ) : t -> t option -> t (** Add if available *) end -val openat : ?dirfd:fd -> sw:Switch.t -> mode:int -> string -> Open_flags.t -> fd +val openat : sw:Switch.t -> mode:int -> dir_fd -> string -> Open_flags.t -> fd (** Note: the returned FD is always non-blocking and close-on-exec. *) module Process : sig @@ -134,3 +140,11 @@ module Process : sig val exit_status : t -> Unix.process_status Promise.t (** [exit_status t] is a promise for the process's exit status. *) end + +(**/**) +(* Exposed for testing only. *) +module Resolve : sig + val open_beneath_fallback : ?dirfd:Unix.file_descr -> sw:Switch.t -> mode:int -> string -> Open_flags.t -> fd + val open_unconfined : sw:Switch.t -> mode:int -> fd option -> string -> Open_flags.t -> fd +end +(**/**) diff --git a/lib_eio_posix/net.ml b/lib_eio_posix/net.ml index 676c2b608..7f9d25fc2 100644 --- a/lib_eio_posix/net.ml +++ b/lib_eio_posix/net.ml @@ -109,7 +109,7 @@ let listen ~reuse_addr ~reuse_port ~backlog ~sw (listen_addr : Eio.Net.Sockaddr. | `Unix path -> if reuse_addr then ( let buf = Low_level.create_stat () in - match Low_level.fstatat ~buf ~follow:false path with + match Low_level.fstatat ~buf ~follow:false Fs path with | () -> if Low_level.kind buf = `Socket then Unix.unlink path | exception Unix.Unix_error (Unix.ENOENT, _, _) -> () | exception Unix.Unix_error (code, name, arg) -> raise @@ Err.wrap code name arg diff --git a/lib_eio_posix/path.ml b/lib_eio_posix/path.ml new file mode 100644 index 000000000..270154d21 --- /dev/null +++ b/lib_eio_posix/path.ml @@ -0,0 +1,81 @@ +type token = + | Empty + | DotDot + | String of string + +let rec tokenise = function + | [] -> [] + | ["."] -> [Empty] (* "path/." is the same as "path/" *) + | "." :: xs -> tokenise xs (* Skip dot if not at end *) + | "" :: xs -> Empty :: tokenise xs + | ".." :: xs -> DotDot :: tokenise xs + | x :: xs -> String x :: tokenise xs + +module Rel = struct + type t = + | Leaf of { basename : string; trailing_slash : bool } + | Self (* A final "." *) + | Child of string * t + | Parent of t + + let rec parse = function + | [] -> Self + | [String basename; Empty] -> Leaf { basename; trailing_slash = true } + | [String basename] -> Leaf { basename; trailing_slash = false } + | [DotDot] -> Parent Self + | DotDot :: xs -> Parent (parse xs) + | String s :: xs -> Child (s, parse xs) + | Empty :: xs -> parse xs + + let parse s = parse (tokenise s) + + let rec concat a b = + match a with + | Leaf { basename; trailing_slash = _ } -> Child (basename, b) + | Child (name, xs) -> Child (name, concat xs b) + | Parent xs -> Parent (concat xs b) + | Self -> b + + let rec dump f = function + | Child (x, xs) -> Fmt.pf f "%S / %a" x dump xs + | Parent xs -> Fmt.pf f ".. / %a" dump xs + | Self -> Fmt.pf f "." + | Leaf { basename; trailing_slash } -> + Fmt.pf f "%S" basename; + if trailing_slash then Fmt.pf f " /" + + let rec segs = function + | Leaf { basename; trailing_slash } -> [if trailing_slash then basename ^ "/" else basename] + | Self -> [""] + | Child (x, xs) -> x :: segs xs + | Parent Self -> [".."] + | Parent xs -> ".." :: segs xs + + let to_string = function + | Self -> "." + | t -> String.concat "/" (segs t) +end + +type t = + | Relative of Rel.t + | Absolute of Rel.t + +let rec parse_abs = function + | "" :: [] -> Absolute Self + | "" :: xs -> parse_abs xs + | xs -> Absolute (Rel.parse xs) + +let parse = function + | "" -> Relative Self + | s -> + match String.split_on_char '/' s with + | "" :: path -> parse_abs path + | path -> Relative (Rel.parse path) + +let dump f = function + | Relative r -> Rel.dump f r + | Absolute r -> Fmt.pf f "/ %a" Rel.dump r + +let to_string = function + | Relative r -> Rel.to_string r + | Absolute r -> String.concat "/" ("" :: Rel.segs r) diff --git a/lib_eio_posix/path.mli b/lib_eio_posix/path.mli new file mode 100644 index 000000000..1577dddd8 --- /dev/null +++ b/lib_eio_posix/path.mli @@ -0,0 +1,26 @@ +module Rel : sig + type t = + | Leaf of { basename : string; trailing_slash : bool } + | Self (* A final "." *) + | Child of string * t + | Parent of t + + val concat : t -> t -> t + + val to_string : t -> string + + val dump : t Fmt.t +end + +type t = + | Relative of Rel.t + | Absolute of Rel.t + +val parse : string -> t +(** Note: + [parse "" = Relative Self] + [parse ".." = Relative (Parent Self)] *) + +val to_string : t -> string + +val dump : t Fmt.t diff --git a/lib_eio_posix/primitives.h b/lib_eio_posix/primitives.h index 9c929c5c5..5a954f78d 100644 --- a/lib_eio_posix/primitives.h +++ b/lib_eio_posix/primitives.h @@ -10,6 +10,7 @@ CAMLprim value caml_eio_posix_writev(value, value); CAMLprim value caml_eio_posix_preadv(value, value, value); CAMLprim value caml_eio_posix_pwritev(value, value, value); CAMLprim value caml_eio_posix_openat(value, value, value, value); +CAMLprim value caml_eio_posix_fdopendir(value); CAMLprim value caml_eio_posix_mkdirat(value, value, value); CAMLprim value caml_eio_posix_unlinkat(value, value, value); CAMLprim value caml_eio_posix_renameat(value, value, value, value); diff --git a/lib_eio_posix/process.ml b/lib_eio_posix/process.ml index c795b104c..8461bb5a1 100644 --- a/lib_eio_posix/process.ml +++ b/lib_eio_posix/process.ml @@ -31,12 +31,11 @@ module Impl = struct let with_actions cwd fn = match cwd with | None -> fn actions | Some ((dir, path) : Eio.Fs.dir_ty Eio.Path.t) -> - match Fs.Handler.as_posix_dir dir with + match Fs.as_posix_dir dir with | None -> Fmt.invalid_arg "cwd is not an OS directory!" - | Some posix -> - Fs.Dir.with_parent_dir posix path @@ fun dirfd s -> + | Some dirfd -> Switch.run ~name:"spawn_unix" @@ fun launch_sw -> - let cwd = Low_level.openat ?dirfd ~sw:launch_sw ~mode:0 s Low_level.Open_flags.(rdonly + directory) in + let cwd = Low_level.openat ~sw:launch_sw ~mode:0 dirfd path Low_level.Open_flags.(rdonly + directory) in fn (Low_level.Process.Fork_action.fchdir cwd :: actions) in with_actions cwd @@ fun actions -> diff --git a/lib_eio_posix/test/dune b/lib_eio_posix/test/dune index 2b12d5166..e6bf775b3 100644 --- a/lib_eio_posix/test/dune +++ b/lib_eio_posix/test/dune @@ -2,3 +2,9 @@ (package eio_posix) (enabled_if (= %{os_type} "Unix")) (deps (package eio_posix))) + +(test + (name open_beneath) + (package eio_posix) + (build_if (= %{os_type} "Unix")) + (libraries eio_posix)) diff --git a/lib_eio_posix/test/open_beneath.ml b/lib_eio_posix/test/open_beneath.ml new file mode 100644 index 000000000..ab5b6ae80 --- /dev/null +++ b/lib_eio_posix/test/open_beneath.ml @@ -0,0 +1,92 @@ +open Eio.Std + +let () = Printexc.record_backtrace true + +module L = Eio_posix.Low_level + +let check ~mode dirfd path flags = + Switch.run @@ fun sw -> + (* traceln "check %S" path; *) + let x = + let path = if path = "" then "." else path in + try Ok (L.Resolve.open_unconfined ~sw ~mode (Some dirfd) path flags) with Unix.Unix_error _ as e -> Error e in + let y = + Eio_unix.Fd.use_exn "check" dirfd @@ fun dirfd -> + try Ok (L.Resolve.open_beneath_fallback ~sw ~dirfd ~mode path flags) with Unix.Unix_error _ as e -> Error e + in + match x, y with + | Ok x, Ok y -> + let inode fd = + let buf = L.create_stat () in + L.fstat fd ~buf; + (L.dev buf, L.ino buf) + in + let x_info = inode x in + let y_info = inode y in + if x_info <> y_info then + Fmt.failwith "Got a different inode opening %S!" path + | Error (Unix.Unix_error (x, _, _) as e1), + Error (Unix.Unix_error (y, _, _) as e2) -> + if x <> y then ( + Fmt.failwith "Different errors: %a vs %a" Fmt.exn e1 Fmt.exn e2 + ) + | Error _, Error _ -> assert false + | Error e, Ok _ -> Fmt.failwith "Only OS open failed: %a" Fmt.exn e + | Ok _, Error e -> Fmt.failwith "Only open_beneath failed: %a" Fmt.exn e + +let test base path = + check ~mode:0 base path L.Open_flags.rdonly; + if path <> "" then ( + check ~mode:0 base (path ^ "/") L.Open_flags.rdonly; + (* This doesn't work on macos, which seems to allow opening files even with + a trailing "/" (but not with a trailing "/."): *) + (* check ~mode:0 base (path ^ "/.") L.Open_flags.rdonly *) + ) + +let test_denied base path = + match L.Open_flags.resolve_beneath with + | Some some_resolve_beneath -> + (* Check our behaviour matches the OS's *) + check ~mode:0 base path L.Open_flags.(rdonly + some_resolve_beneath) + | None -> + (* traceln "check-reject %S" path; *) + (* OS doesn't support resolve_beneath. Just check we reject it. *) + Switch.run @@ fun sw -> + Eio_unix.Fd.use_exn "check" base @@ fun base -> + match L.Resolve.open_beneath_fallback ~sw ~dirfd:base ~mode:0 path L.Open_flags.rdonly with + | (_fd : Eio_unix.Fd.t) -> Fmt.failwith "%S should have been rejected!" path + | exception Eio.Io (Eio.Fs.E Permission_denied _, _) -> () + +let () = + try + Eio_posix.run @@ fun _env -> + Unix.mkdir "test_beneath" 0o700; + Unix.mkdir "test_beneath/subdir" 0o700; + Unix.symlink "subdir" "test_beneath/link_subdir"; + Unix.symlink ".." "test_beneath/link_subdir/parent"; + Unix.symlink ".." "test_beneath/parent"; + Unix.symlink "loop2" "test_beneath/loop1"; + Unix.symlink "loop1" "test_beneath/loop2"; + Unix.symlink "file" "test_beneath/to-file"; + Unix.symlink "file/" "test_beneath/to-file-slash"; + Unix.symlink "subdir/" "test_beneath/to-dir-slash"; + Switch.run @@ fun sw -> + let test_dir = L.Resolve.open_beneath_fallback ~sw "test_beneath" L.Open_flags.directory ~mode:0 in + let f = L.openat ~sw (Fd test_dir) "file" L.Open_flags.(creat + rdwr) ~mode:0o600 in + Eio_unix.Fd.close f; + test test_dir "file"; + test test_dir "subdir"; + test test_dir "link_subdir"; + test test_dir "link_subdir/parent"; + test_denied test_dir "link_subdir/parent/parent"; + test test_dir ""; + test test_dir "."; + test_denied test_dir ".."; + test test_dir "loop1"; + test test_dir "to-file"; + test test_dir "to-file-slash"; + test test_dir "to-dir-slash/file"; + with Failure msg -> + Printexc.print_backtrace stderr; + Fmt.epr "Tests failed: %s" msg; + exit 1 diff --git a/lib_eio_posix/test/path.md b/lib_eio_posix/test/path.md new file mode 100644 index 000000000..791660511 --- /dev/null +++ b/lib_eio_posix/test/path.md @@ -0,0 +1,58 @@ +```ocaml +# #require "eio_posix" +``` +```ocaml +module P = Eio_posix__Path + +let dump f p = + Fmt.pf f "%a (%S)" P.dump p (P.to_string p) +``` + +```ocaml +# #install_printer dump;; + +# P.parse "foo" +- : P.t = "foo" ("foo") + +# P.parse "foo/bar" +- : P.t = "foo" / "bar" ("foo/bar") + +# P.parse "foo//bar/" +- : P.t = "foo" / "bar" / ("foo/bar/") + +# P.parse "foo/." +- : P.t = "foo" / ("foo/") + +# P.parse "foo/./" +- : P.t = "foo" / ("foo/") + +# P.parse "" +- : P.t = . (".") + +# P.parse "." +- : P.t = . (".") + +# P.parse ".." +- : P.t = .. / . ("..") + +# P.parse "./../.././.." +- : P.t = .. / .. / .. / . ("../../..") + +# P.parse "/" +- : P.t = / . ("/") + +# P.parse "/etc" +- : P.t = / "etc" ("/etc") + +# P.parse "/etc/passwd" +- : P.t = / "etc" / "passwd" ("/etc/passwd") + +# P.parse "/." +- : P.t = / . ("/") + +# P.parse "/.." +- : P.t = / .. / . ("/..") + +# P.parse "//../" +- : P.t = / .. / . ("/..") +``` diff --git a/lib_eio_posix/test/spawn.md b/lib_eio_posix/test/spawn.md index a6f678820..dc41a114b 100644 --- a/lib_eio_posix/test/spawn.md +++ b/lib_eio_posix/test/spawn.md @@ -46,7 +46,7 @@ Changing directory using a file descriptor: ```ocaml # Eio_posix.run @@ fun _env -> Switch.run @@ fun sw -> - let root = Eio_posix.Low_level.openat ~sw ~mode:0 "/" Eio_posix.Low_level.Open_flags.(rdonly + directory) in + let root = Eio_posix.Low_level.openat ~sw ~mode:0 Fs "/" Eio_posix.Low_level.Open_flags.(rdonly + directory) in let child = Process.spawn ~sw Process.Fork_action.[ fchdir root; execve "/usr/bin/env" diff --git a/tests/fs.md b/tests/fs.md index de8c90fb1..e7401d10b 100644 --- a/tests/fs.md +++ b/tests/fs.md @@ -542,8 +542,10 @@ Create a sandbox, write a file with it, then read it from outside: test (fs / "foo/bar"); Unix.symlink ".." "foo/up"; test (cwd / "foo/up/foo/bar"); + reject (cwd / "foo/up/../bar"); Unix.symlink "/" "foo/root"; reject (cwd / "foo/root/.."); + reject (cwd / "missing"); +open_dir -> OK +Eio.Io Fs Permission_denied _, opening directory +open_dir -> OK @@ -551,7 +553,9 @@ Create a sandbox, write a file with it, then read it from outside: +open_dir -> OK +open_dir -> OK +open_dir -> OK ++Eio.Io Fs Permission_denied _, opening directory +Eio.Io Fs Permission_denied _, opening directory ++Eio.Io Fs Not_found _, opening directory - : unit = () # Eio.Exn.Backend.show := false @@ -595,17 +599,22 @@ Reading directory entries under `cwd` and outside of `cwd`. Path.with_open_dir (cwd / "readdir") @@ fun tmpdir -> try_mkdir (tmpdir / "test-1"); try_mkdir (tmpdir / "test-2"); + try_write_file ~create:(`Exclusive 0o600) (tmpdir / "test-1/file") "data"; try_read_dir tmpdir; try_read_dir (tmpdir / "."); try_read_dir (tmpdir / ".."); - try_read_dir (tmpdir / "test-3");; + try_read_dir (tmpdir / "test-3"); + Unix.symlink "test-1" "readdir/link-1"; + try_read_dir (tmpdir / "link-1"); +mkdir -> ok +mkdir -> ok +mkdir -> ok ++write -> ok +read_dir -> ["test-1"; "test-2"] +read_dir -> ["test-1"; "test-2"] +Eio.Io Fs Permission_denied _, reading directory +Eio.Io Fs Not_found _, reading directory ++read_dir -> ["file"] - : unit = () ```