Skip to main content

lib/wick/protocol/request.ex

defmodule Wick.Protocol.Request do
  @moduledoc """
  Opcode-specific request structs and `decode/2` dispatcher.

  Each opcode's payload layout is taken verbatim from Linux's
  `include/uapi/linux/fuse.h` v7.31. Filenames in request bodies are
  always NUL-terminated and are decoded without the terminator.
  """

  # One module per request kind, all defined here to keep the
  # protocol codec in a single place. Structs are flat and carry only
  # the fields the handler cares about — padding / unused fields are
  # dropped on decode.

  defmodule Init do
    @moduledoc "FUSE_INIT request — `fuse_init_in` (16 bytes)."
    defstruct [:major, :minor, :max_readahead, :flags]

    @type t :: %__MODULE__{
            major: non_neg_integer(),
            minor: non_neg_integer(),
            max_readahead: non_neg_integer(),
            flags: non_neg_integer()
          }
  end

  defmodule Destroy do
    @moduledoc "FUSE_DESTROY request — empty body."
    defstruct []
    @type t :: %__MODULE__{}
  end

  defmodule Lookup do
    @moduledoc """
    FUSE_LOOKUP request — a NUL-terminated name. The parent directory
    is the `nodeid` in the request header.
    """
    defstruct [:name]
    @type t :: %__MODULE__{name: String.t()}
  end

  defmodule Forget do
    @moduledoc "FUSE_FORGET — `fuse_forget_in` (8 bytes)."
    defstruct [:nlookup]
    @type t :: %__MODULE__{nlookup: non_neg_integer()}
  end

  defmodule BatchForget do
    @moduledoc """
    FUSE_BATCH_FORGET — `fuse_batch_forget_in` (8 bytes) followed by
    `count` × `fuse_forget_one` records. Exposed as a list of
    `{nodeid, nlookup}` tuples.
    """
    defstruct [:items]
    @type t :: %__MODULE__{items: [{non_neg_integer(), non_neg_integer()}]}
  end

  defmodule GetAttr do
    @moduledoc "FUSE_GETATTR — `fuse_getattr_in` (16 bytes)."
    defstruct [:getattr_flags, :fh]
    @type t :: %__MODULE__{getattr_flags: non_neg_integer(), fh: non_neg_integer()}
  end

  defmodule SetAttr do
    @moduledoc "FUSE_SETATTR — `fuse_setattr_in` (88 bytes)."
    defstruct [
      :valid,
      :fh,
      :size,
      :lock_owner,
      :atime,
      :mtime,
      :ctime,
      :atimensec,
      :mtimensec,
      :ctimensec,
      :mode,
      :uid,
      :gid
    ]

    @type t :: %__MODULE__{
            valid: non_neg_integer(),
            fh: non_neg_integer(),
            size: non_neg_integer(),
            lock_owner: non_neg_integer(),
            atime: non_neg_integer(),
            mtime: non_neg_integer(),
            ctime: non_neg_integer(),
            atimensec: non_neg_integer(),
            mtimensec: non_neg_integer(),
            ctimensec: non_neg_integer(),
            mode: non_neg_integer(),
            uid: non_neg_integer(),
            gid: non_neg_integer()
          }
  end

  defmodule Mkdir do
    @moduledoc "FUSE_MKDIR — `fuse_mkdir_in` (8 bytes) + name + NUL."
    defstruct [:mode, :umask, :name]

    @type t :: %__MODULE__{
            mode: non_neg_integer(),
            umask: non_neg_integer(),
            name: String.t()
          }
  end

  defmodule Unlink do
    @moduledoc "FUSE_UNLINK — name + NUL. Parent nodeid in header."
    defstruct [:name]
    @type t :: %__MODULE__{name: String.t()}
  end

  defmodule Rmdir do
    @moduledoc "FUSE_RMDIR — name + NUL. Parent nodeid in header."
    defstruct [:name]
    @type t :: %__MODULE__{name: String.t()}
  end

  defmodule Rename do
    @moduledoc """
    FUSE_RENAME (12) — `fuse_rename_in` (8 bytes) with the new parent
    `nodeid`, then oldname+NUL, then newname+NUL. The old parent is in
    the request header.
    """
    defstruct [:newdir, :oldname, :newname]

    @type t :: %__MODULE__{
            newdir: non_neg_integer(),
            oldname: String.t(),
            newname: String.t()
          }
  end

  defmodule Rename2 do
    @moduledoc "FUSE_RENAME2 (45) — like RENAME plus a `flags` field."
    defstruct [:newdir, :flags, :oldname, :newname]

    @type t :: %__MODULE__{
            newdir: non_neg_integer(),
            flags: non_neg_integer(),
            oldname: String.t(),
            newname: String.t()
          }
  end

  defmodule Open do
    @moduledoc "FUSE_OPEN — `fuse_open_in` (8 bytes)."
    defstruct [:flags]
    @type t :: %__MODULE__{flags: non_neg_integer()}
  end

  defmodule Release do
    @moduledoc "FUSE_RELEASE — `fuse_release_in` (24 bytes)."
    defstruct [:fh, :flags, :release_flags, :lock_owner]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            flags: non_neg_integer(),
            release_flags: non_neg_integer(),
            lock_owner: non_neg_integer()
          }
  end

  defmodule Read do
    @moduledoc "FUSE_READ — `fuse_read_in` (40 bytes)."
    defstruct [:fh, :offset, :size, :read_flags, :lock_owner, :flags]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            offset: non_neg_integer(),
            size: non_neg_integer(),
            read_flags: non_neg_integer(),
            lock_owner: non_neg_integer(),
            flags: non_neg_integer()
          }
  end

  defmodule Readdir do
    @moduledoc """
    FUSE_READDIR — `fuse_read_in` (40 bytes). Same wire layout as
    `Read`, but `offset` is a directory-stream cookie.
    """
    defstruct [:fh, :offset, :size, :read_flags, :lock_owner, :flags]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            offset: non_neg_integer(),
            size: non_neg_integer(),
            read_flags: non_neg_integer(),
            lock_owner: non_neg_integer(),
            flags: non_neg_integer()
          }
  end

  defmodule ReaddirPlus do
    @moduledoc """
    FUSE_READDIRPLUS — same wire layout as `Readdir` (`fuse_read_in`,
    40 bytes). The reply layout differs (each entry carries inline
    attributes via `fuse_direntplus`).
    """
    defstruct [:fh, :offset, :size, :read_flags, :lock_owner, :flags]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            offset: non_neg_integer(),
            size: non_neg_integer(),
            read_flags: non_neg_integer(),
            lock_owner: non_neg_integer(),
            flags: non_neg_integer()
          }
  end

  defmodule Write do
    @moduledoc """
    FUSE_WRITE — `fuse_write_in` (40 bytes) plus `size` bytes of
    payload.
    """
    defstruct [:fh, :offset, :size, :write_flags, :lock_owner, :flags, :data]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            offset: non_neg_integer(),
            size: non_neg_integer(),
            write_flags: non_neg_integer(),
            lock_owner: non_neg_integer(),
            flags: non_neg_integer(),
            data: binary()
          }
  end

  defmodule Statfs do
    @moduledoc "FUSE_STATFS — empty body."
    defstruct []
    @type t :: %__MODULE__{}
  end

  defmodule Flush do
    @moduledoc "FUSE_FLUSH — `fuse_flush_in` (24 bytes)."
    defstruct [:fh, :lock_owner]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            lock_owner: non_neg_integer()
          }
  end

  defmodule Fsync do
    @moduledoc "FUSE_FSYNC — `fuse_fsync_in` (16 bytes)."
    defstruct [:fh, :fsync_flags]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            fsync_flags: non_neg_integer()
          }
  end

  defmodule FsyncDir do
    @moduledoc """
    FUSE_FSYNCDIR — same wire layout as `Fsync` (`fuse_fsync_in`,
    16 bytes); semantics differ (it flushes the directory's
    metadata rather than the file's data).
    """
    defstruct [:fh, :fsync_flags]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            fsync_flags: non_neg_integer()
          }
  end

  defmodule Fallocate do
    @moduledoc """
    FUSE_FALLOCATE — `fuse_fallocate_in` (32 bytes). Wick doesn't
    support sparse pre-allocation, so the session will reply
    `-ENOSYS` regardless of the fields; we still parse the body so
    decode-time validation works.
    """
    defstruct [:fh, :offset, :length, :mode]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            offset: non_neg_integer(),
            length: non_neg_integer(),
            mode: non_neg_integer()
          }
  end

  defmodule Create do
    @moduledoc "FUSE_CREATE — `fuse_create_in` (16 bytes) + name + NUL."
    defstruct [:flags, :mode, :umask, :name]

    @type t :: %__MODULE__{
            flags: non_neg_integer(),
            mode: non_neg_integer(),
            umask: non_neg_integer(),
            name: String.t()
          }
  end

  defmodule SetXattr do
    @moduledoc """
    FUSE_SETXATTR — `fuse_setxattr_in` (8 bytes for v7.31) + name + NUL + value.

    `flags` is the POSIX `XATTR_CREATE` / `XATTR_REPLACE` bitmask. The
    extended `setxattr_flags` field added in v7.33 is not parsed —
    callers wanting that behaviour must negotiate `FUSE_SETXATTR_EXT`,
    which we don't yet advertise.
    """
    defstruct [:size, :flags, :name, :value]

    @type t :: %__MODULE__{
            size: non_neg_integer(),
            flags: non_neg_integer(),
            name: binary(),
            value: binary()
          }
  end

  defmodule GetXattr do
    @moduledoc """
    FUSE_GETXATTR — `fuse_getxattr_in` (8 bytes) + name + NUL.

    `size = 0` is the size-probe convention: the kernel asks us how
    big the value is so it can allocate a buffer; `size > 0` is the
    real fetch with a buffer that can hold up to that many bytes.
    """
    defstruct [:size, :name]

    @type t :: %__MODULE__{size: non_neg_integer(), name: binary()}
  end

  defmodule ListXattr do
    @moduledoc """
    FUSE_LISTXATTR — `fuse_getxattr_in` (8 bytes), no name. The
    nodeid in the request header identifies the file. Same size-probe
    convention as `GetXattr`.
    """
    defstruct [:size]

    @type t :: %__MODULE__{size: non_neg_integer()}
  end

  defmodule RemoveXattr do
    @moduledoc """
    FUSE_REMOVEXATTR — name + NUL. Nodeid in header.
    """
    defstruct [:name]

    @type t :: %__MODULE__{name: binary()}
  end

  defmodule FileLock do
    @moduledoc """
    `fuse_file_lock` (24 bytes) — embedded in `GetLk` / `SetLk`
    requests and the matching `GetLkReply`. `start` / `end` are
    byte-range bounds (ignored for FLOCK semantics). `type` is one
    of 0 (`F_RDLCK`) / 1 (`F_WRLCK`) / 2 (`F_UNLCK`). `pid` is the
    requesting process id (kernel fills it for us; we echo it in
    GetLk replies).
    """
    defstruct start: 0, end: 0, type: 0, pid: 0

    @type t :: %__MODULE__{
            start: non_neg_integer(),
            end: non_neg_integer(),
            type: non_neg_integer(),
            pid: non_neg_integer()
          }
  end

  defmodule GetLk do
    @moduledoc """
    FUSE_GETLK — `fuse_lk_in` (48 bytes). Probes for a conflicting
    byte-range lock. `lk_flags & FUSE_LK_FLOCK` (= 1) is meaningless
    for GETLK in POSIX terms — the kernel doesn't issue GETLK for
    flock when `FUSE_FLOCK_LOCKS` is advertised; handlers fall back
    to F_UNLCK if it ever does.
    """
    defstruct [:fh, :owner, :lk, :lk_flags]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            owner: non_neg_integer(),
            lk: FileLock.t(),
            lk_flags: non_neg_integer()
          }
  end

  defmodule SetLk do
    @moduledoc """
    FUSE_SETLK / FUSE_SETLKW — `fuse_lk_in` (48 bytes). The
    `lk_flags & FUSE_LK_FLOCK` (= 1) bit distinguishes whole-file
    `flock(2)` semantics from POSIX byte-range `fcntl(F_SETLK)`
    semantics. The same struct decodes both opcodes; the request
    atom (`:setlk` vs `:setlkw`) tells the handler whether to block.
    """
    defstruct [:fh, :owner, :lk, :lk_flags]

    @type t :: %__MODULE__{
            fh: non_neg_integer(),
            owner: non_neg_integer(),
            lk: FileLock.t(),
            lk_flags: non_neg_integer()
          }
  end

  defmodule Interrupt do
    @moduledoc """
    FUSE_INTERRUPT — `fuse_interrupt_in` (8 bytes). Carries the
    `unique` of a previously-issued in-flight request that the
    kernel wants to cancel (e.g. because userspace received a
    signal during a blocking SETLKW).

    INTERRUPT itself has no reply on the wire — the original
    request is what the kernel waits for, typically with `EINTR`.
    Handlers that don't recognise the target unique should silently
    drop the message (the original may have already replied or
    might never have been blockable).
    """
    defstruct [:unique]

    @type t :: %__MODULE__{unique: non_neg_integer()}
  end

  @type t ::
          Init.t()
          | Destroy.t()
          | Lookup.t()
          | Forget.t()
          | BatchForget.t()
          | GetAttr.t()
          | SetAttr.t()
          | Mkdir.t()
          | Unlink.t()
          | Rmdir.t()
          | Rename.t()
          | Rename2.t()
          | Open.t()
          | Release.t()
          | Read.t()
          | Readdir.t()
          | ReaddirPlus.t()
          | Write.t()
          | Statfs.t()
          | Flush.t()
          | Fsync.t()
          | Create.t()
          | SetXattr.t()
          | GetXattr.t()
          | ListXattr.t()
          | RemoveXattr.t()
          | GetLk.t()
          | SetLk.t()
          | Interrupt.t()

  @doc """
  Decode a body for the given opcode. Returns the populated struct or
  `{:error, :malformed_body}` when the bytes don't match the expected
  layout.
  """
  @spec decode(atom(), binary()) :: {:ok, t()} | {:error, :malformed_body}
  # The kernel sends a `fuse_init_in` body sized for ITS protocol
  # version, which may be larger than the v7.31 we decode against
  # (modern kernels send 64 bytes for v7.36+). Accept any size ≥ 16
  # and ignore the trailing bytes — we only act on the four named
  # fields here, and the v7.31 INIT reply we send back signals the
  # negotiated minor version.
  def decode(
        :init,
        <<major::little-32, minor::little-32, mra::little-32, flags::little-32, _rest::binary>>
      ),
      do: {:ok, %Init{major: major, minor: minor, max_readahead: mra, flags: flags}}

  def decode(:destroy, <<>>), do: {:ok, %Destroy{}}

  def decode(:lookup, body) when is_binary(body) do
    case strip_cstring(body) do
      {:ok, name, <<>>} -> {:ok, %Lookup{name: name}}
      _ -> {:error, :malformed_body}
    end
  end

  def decode(:forget, <<nlookup::little-64>>),
    do: {:ok, %Forget{nlookup: nlookup}}

  def decode(:batch_forget, <<count::little-32, _dummy::little-32, rest::binary>>) do
    case decode_forget_list(rest, count, []) do
      {:ok, items} -> {:ok, %BatchForget{items: items}}
      :error -> {:error, :malformed_body}
    end
  end

  def decode(:getattr, <<flags::little-32, _dummy::little-32, fh::little-64>>),
    do: {:ok, %GetAttr{getattr_flags: flags, fh: fh}}

  def decode(:setattr, <<
        valid::little-32,
        _pad0::little-32,
        fh::little-64,
        size::little-64,
        lock_owner::little-64,
        atime::little-64,
        mtime::little-64,
        ctime::little-64,
        atimensec::little-32,
        mtimensec::little-32,
        ctimensec::little-32,
        mode::little-32,
        _unused4::little-32,
        uid::little-32,
        gid::little-32,
        _unused5::little-32
      >>) do
    {:ok,
     %SetAttr{
       valid: valid,
       fh: fh,
       size: size,
       lock_owner: lock_owner,
       atime: atime,
       mtime: mtime,
       ctime: ctime,
       atimensec: atimensec,
       mtimensec: mtimensec,
       ctimensec: ctimensec,
       mode: mode,
       uid: uid,
       gid: gid
     }}
  end

  def decode(:mkdir, <<mode::little-32, umask::little-32, rest::binary>>) do
    case strip_cstring(rest) do
      {:ok, name, <<>>} -> {:ok, %Mkdir{mode: mode, umask: umask, name: name}}
      _ -> {:error, :malformed_body}
    end
  end

  def decode(:unlink, body), do: decode_single_name(body, Unlink)
  def decode(:rmdir, body), do: decode_single_name(body, Rmdir)

  def decode(:rename, <<newdir::little-64, rest::binary>>) do
    case strip_two_cstrings(rest) do
      {:ok, oldname, newname} ->
        {:ok, %Rename{newdir: newdir, oldname: oldname, newname: newname}}

      :error ->
        {:error, :malformed_body}
    end
  end

  def decode(:rename2, <<newdir::little-64, flags::little-32, _pad::little-32, rest::binary>>) do
    case strip_two_cstrings(rest) do
      {:ok, oldname, newname} ->
        {:ok, %Rename2{newdir: newdir, flags: flags, oldname: oldname, newname: newname}}

      :error ->
        {:error, :malformed_body}
    end
  end

  def decode(:open, <<flags::little-32, _unused::little-32>>),
    do: {:ok, %Open{flags: flags}}

  # OPENDIR uses the same `fuse_open_in` layout as OPEN — share the
  # decoder and the result struct.
  def decode(:opendir, body), do: decode(:open, body)

  def decode(:release, <<
        fh::little-64,
        flags::little-32,
        release_flags::little-32,
        lock_owner::little-64
      >>) do
    {:ok, %Release{fh: fh, flags: flags, release_flags: release_flags, lock_owner: lock_owner}}
  end

  # RELEASEDIR uses the same `fuse_release_in` layout as RELEASE.
  def decode(:releasedir, body), do: decode(:release, body)

  def decode(:read, body), do: decode_read_like(body, Read)
  def decode(:readdir, body), do: decode_read_like(body, Readdir)
  def decode(:readdirplus, body), do: decode_read_like(body, ReaddirPlus)

  def decode(:write, <<
        fh::little-64,
        offset::little-64,
        size::little-32,
        write_flags::little-32,
        lock_owner::little-64,
        flags::little-32,
        _pad::little-32,
        payload::binary
      >>)
      when byte_size(payload) == size do
    {:ok,
     %Write{
       fh: fh,
       offset: offset,
       size: size,
       write_flags: write_flags,
       lock_owner: lock_owner,
       flags: flags,
       data: payload
     }}
  end

  def decode(:statfs, <<>>), do: {:ok, %Statfs{}}

  def decode(
        :flush,
        <<fh::little-64, _unused::little-32, _pad::little-32, lock_owner::little-64>>
      ),
      do: {:ok, %Flush{fh: fh, lock_owner: lock_owner}}

  def decode(:fsync, <<fh::little-64, fsync_flags::little-32, _pad::little-32>>),
    do: {:ok, %Fsync{fh: fh, fsync_flags: fsync_flags}}

  def decode(:fsyncdir, <<fh::little-64, fsync_flags::little-32, _pad::little-32>>),
    do: {:ok, %FsyncDir{fh: fh, fsync_flags: fsync_flags}}

  def decode(:fallocate, <<
        fh::little-64,
        offset::little-64,
        length::little-64,
        mode::little-32,
        _pad::little-32
      >>),
      do: {:ok, %Fallocate{fh: fh, offset: offset, length: length, mode: mode}}

  def decode(:create, <<
        flags::little-32,
        mode::little-32,
        umask::little-32,
        _pad::little-32,
        rest::binary
      >>) do
    case strip_cstring(rest) do
      {:ok, name, <<>>} ->
        {:ok, %Create{flags: flags, mode: mode, umask: umask, name: name}}

      _ ->
        {:error, :malformed_body}
    end
  end

  def decode(:setxattr, <<size::little-32, flags::little-32, rest::binary>>) do
    with {:ok, name, value} <- strip_cstring(rest),
         true <- byte_size(value) == size do
      {:ok, %SetXattr{size: size, flags: flags, name: name, value: value}}
    else
      _ -> {:error, :malformed_body}
    end
  end

  def decode(:getxattr, <<size::little-32, _pad::little-32, rest::binary>>) do
    case strip_cstring(rest) do
      {:ok, name, <<>>} -> {:ok, %GetXattr{size: size, name: name}}
      _ -> {:error, :malformed_body}
    end
  end

  def decode(:listxattr, <<size::little-32, _pad::little-32>>),
    do: {:ok, %ListXattr{size: size}}

  def decode(:removexattr, body) when is_binary(body) do
    case strip_cstring(body) do
      {:ok, name, <<>>} -> {:ok, %RemoveXattr{name: name}}
      _ -> {:error, :malformed_body}
    end
  end

  def decode(:getlk, body), do: decode_lk(body, GetLk)
  def decode(:setlk, body), do: decode_lk(body, SetLk)
  def decode(:setlkw, body), do: decode_lk(body, SetLk)

  def decode(:interrupt, <<unique::little-64>>), do: {:ok, %Interrupt{unique: unique}}

  def decode(_, _), do: {:error, :malformed_body}

  defp decode_lk(
         <<
           fh::little-64,
           owner::little-64,
           start::little-64,
           lk_end::little-64,
           type::little-32,
           pid::little-32,
           lk_flags::little-32,
           _padding::little-32
         >>,
         mod
       ) do
    {:ok,
     struct(mod,
       fh: fh,
       owner: owner,
       lk: %FileLock{start: start, end: lk_end, type: type, pid: pid},
       lk_flags: lk_flags
     )}
  end

  defp decode_lk(_, _), do: {:error, :malformed_body}

  # ——— Private helpers ————————————————————————————————————————————

  defp decode_single_name(body, mod) do
    case strip_cstring(body) do
      {:ok, name, <<>>} -> {:ok, struct(mod, name: name)}
      _ -> {:error, :malformed_body}
    end
  end

  defp decode_read_like(
         <<
           fh::little-64,
           offset::little-64,
           size::little-32,
           read_flags::little-32,
           lock_owner::little-64,
           flags::little-32,
           _pad::little-32
         >>,
         mod
       ) do
    {:ok,
     struct(mod,
       fh: fh,
       offset: offset,
       size: size,
       read_flags: read_flags,
       lock_owner: lock_owner,
       flags: flags
     )}
  end

  defp decode_read_like(_, _), do: {:error, :malformed_body}

  defp strip_cstring(binary) do
    case :binary.split(binary, <<0>>) do
      [name, rest] -> {:ok, name, rest}
      _ -> :error
    end
  end

  defp strip_two_cstrings(binary) do
    with {:ok, first, rest} <- strip_cstring(binary),
         {:ok, second, <<>>} <- strip_cstring(rest) do
      {:ok, first, second}
    else
      _ -> :error
    end
  end

  defp decode_forget_list(<<>>, 0, acc), do: {:ok, Enum.reverse(acc)}

  defp decode_forget_list(
         <<nodeid::little-64, nlookup::little-64, rest::binary>>,
         n,
         acc
       )
       when n > 0 do
    decode_forget_list(rest, n - 1, [{nodeid, nlookup} | acc])
  end

  defp decode_forget_list(_, _, _), do: :error
end