# `Raxol.AgentClientProtocol.Client.FsSandbox`
[🔗](https://github.com/DROOdotFOO/raxol-agent/blob/raxol_agent_client_protocol-v0.1.0/packages/raxol_agent_client_protocol/lib/raxol/agent_client_protocol/client.ex#L697)

Confines `fs/read_text_file` / `fs/write_text_file` handling to a sandbox
directory -- backs `use Raxol.AgentClientProtocol.Client, fs_sandbox:
path` (see that module's moduledoc for the option). Not a general-purpose
path library: it exists solely to give a minimal client a working
filesystem story out of the box.

## Path checks

Every request path is joined under the sandbox root and lexically
normalized (`..` resolved via `Path.expand/1`), checked against the root,
THEN fully symlink-resolved (`resolve/2`, a hand-rolled `realpath`) and
checked against the root a second time -- so a `../` escape and a
symlink planted inside the sandbox that points outside it are both
caught, not just the lexical case. Every refusal is a `-32602 invalid
params` `Error.t()`, safe to send straight to the peer: `data` carries a
`"reason"` and the path the peer sent, never the resolved absolute path.

## Opening the file

A path that is checked and then opened leaves a window in which a peer
that can change the tree inside the root swaps the file for a symlink. So
the file is opened first and the open handle verified afterwards: the
device and inode of the handle (`:file.read_file_info/1` on it) must equal
those of the path re-resolved with `resolve/2` and read with
`File.lstat/1`, which does not follow a symlink at the leaf, and both
must be a regular file. A mismatch is refused as `"path_changed"`; a leaf
that is not a regular file (a directory, FIFO, device, or a symlink
swapped in for one) as `"not_a_regular_file"`.

  * `read/3` checks the leaf with `File.lstat/1`, opens it read-only,
    verifies the handle, and reads at most `:max_read_bytes` (default
    16 MiB); a larger file is refused as `"file_too_large"`. `line` and
    `limit` slice what was read.
  * `write/2` creates missing parent directories one at a time, each
    checked to be a directory and not a symlink, and re-resolves the path
    afterwards. A new file is created with `O_EXCL`, which never follows
    a symlink at the leaf; an existing file is opened without truncating
    it. The handle is verified before it is truncated and written.

## What is and is not guaranteed

Erlang's file API has no `openat` or `O_NOFOLLOW`, so checking a path and
opening it are separate system calls. With the verification above:

  * Swapping the requested file itself (for a symlink, a FIFO, a device or
    another file) while a request runs cannot make the request read or
    write a file outside the root.
  * A peer that can also rename the DIRECTORIES a requested path goes
    through, and wins two races within one request (at the open and at
    the verifying `lstat`), can still redirect that request. A path whose
    only directory is the root itself is not exposed to this.
  * There is no open mode that writes an existing file without `O_CREAT`,
    so a leaf swapped for a dangling symlink at that instant, or a parent
    directory swapped as above, can create an EMPTY file or directory
    outside the root. Nothing is written into it: the handle check
    refuses first.
  * A leaf swapped for a FIFO between the `lstat` and the read-only open
    blocks that one request until the FIFO has a writer.
  * A hard link inside the root to a file outside it is that file; hard
    links are not detected.
  * On non-Unix file systems the inode is reported as zero, so the handle
    check compares only the device and the file type.

`fs_sandbox` confines the paths the peer names. It is not a boundary
against a peer that can also change the sandbox tree by other means
while requests run: the checks above narrow those races but do not close
them.

# `read`

```elixir
@spec read(String.t(), struct(), keyword()) ::
  {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.ReadTextFileResponse.t()}
  | {:error, Raxol.AgentClientProtocol.Error.t()}
```

Handle `fs/read_text_file` (`req` is a `ClientTypes.ReadTextFileRequest.t()`)
confined to `sandbox_root`.

Options: `:max_read_bytes` (default 16 MiB), the largest file read; a
larger one is refused as `"file_too_large"`.

# `resolve`

```elixir
@spec resolve(String.t(), String.t()) ::
  {:ok, String.t()} | {:error, Raxol.AgentClientProtocol.Error.t()}
```

Resolve `requested_path` against `sandbox_root`, rejecting any result
that escapes the sandbox -- lexically (`../` traversal) or via a symlink
(on the requested file itself or on any ancestor directory in its path).
Returns `{:ok, real_absolute_path}` or `{:error, %Error{}}`
(`-32602 invalid params`).

# `write`

```elixir
@spec write(String.t(), struct()) ::
  {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.WriteTextFileResponse.t()}
  | {:error, Raxol.AgentClientProtocol.Error.t()}
```

Handle `fs/write_text_file` (`req` is a `ClientTypes.WriteTextFileRequest.t()`) confined to `sandbox_root`. Creates parent directories as needed.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
