Sftpd.Backend behaviour (Sftpd v0.1.1)

Copy Markdown View Source

Behaviour for SFTP storage backends.

See the HexDocs extras Backends and Custom Backends for package-level guidance before implementing this behaviour directly.

Implement this behaviour to create custom storage backends for the SFTP server. Built-in backends include:

Module-Based Backends

Implement the Sftpd.Backend behaviour:

defmodule MyApp.CustomBackend do
  @behaviour Sftpd.Backend

  @impl true
  def init(opts) do
    {:ok, %{root: opts[:root] || "/"}}
  end

  @impl true
  def list_dir(path, state) do
    {:ok, [~c".", ~c"..", ~c"file.txt"]}
  end

  # ... implement other callbacks
end

Then use it:

Sftpd.start_server(
  backend: MyApp.CustomBackend,
  backend_opts: [root: "/data"],
  ...
)

Process-Based Backends

For stateful backends, you can use a GenServer process instead:

Sftpd.start_server(
  backend: {:genserver, MyApp.BackendServer},
  ...
)

By default, the process receives the legacy message shapes:

def handle_call({:list_dir, path}, _from, state)
def handle_call({:file_info, path}, _from, state)
def handle_call({:make_dir, path}, _from, state)
def handle_call({:del_dir, path}, _from, state)
def handle_call({:delete, path}, _from, state)
def handle_call({:rename, src, dst}, _from, state)
def handle_call({:read_file, path}, _from, state)
def handle_call({:write_file, path, content}, _from, state)

Opt in to authenticated session context with {:genserver, server, session: true}. Session-aware processes must handle:

def handle_call({:list_dir, path, session}, _from, state)
def handle_call({:file_info, path, session}, _from, state)
def handle_call({:make_dir, path, session}, _from, state)
def handle_call({:del_dir, path, session}, _from, state)
def handle_call({:delete, path, session}, _from, state)
def handle_call({:rename, src, dst, session}, _from, state)
def handle_call({:read_file, path, session}, _from, state)
def handle_call({:write_file, path, content, session}, _from, state)

Each should reply with the same format as the behaviour callbacks.

Optional Streaming Callbacks

Module backends can implement optional streaming callbacks for more efficient large-file transfers:

  • read_file_range/4
  • begin_write/2
  • write_chunk/4
  • finish_write/2
  • abort_write/2

When these callbacks are present, Sftpd.IODevice avoids buffering entire files in memory for reads and most writes.

Close Semantics

Erlang's stock :ssh_sftpd server ignores the return value of file_handler.close/2 and always reports close success to the client. Write failures can therefore only be surfaced reliably during active writes, not on close/final multipart completion.

Summary

Types

Erlang file_info tuple

SFTP path as charlist

Authenticated SSH session context returned by Sftpd.Auth callbacks

Backend state, returned from init/1 and threaded through all calls

Opaque backend-managed write handle used by optional streaming callbacks

Functions

Build a file_info tuple for a directory.

Build a file_info tuple for a regular file.

Normalize an SFTP path to a string without leading slash.

Return true if the path refers to the root directory.

Return true when a module backend implements the given optional callback.

Types

file_info()

@type file_info() ::
  {:file_info, non_neg_integer(), :regular | :directory,
   :read | :write | :read_write, tuple(), tuple(), tuple(), non_neg_integer(),
   non_neg_integer(), non_neg_integer(), non_neg_integer(), non_neg_integer(),
   non_neg_integer(), non_neg_integer()}

Erlang file_info tuple

genserver_backend()

@type genserver_backend() ::
  {:genserver, GenServer.server()} | {:genserver, GenServer.server(), keyword()}

path()

@type path() :: charlist()

SFTP path as charlist

session()

@type session() :: map()

Authenticated SSH session context returned by Sftpd.Auth callbacks

state()

@type state() :: term()

Backend state, returned from init/1 and threaded through all calls

writer_handle()

@type writer_handle() :: term()

Opaque backend-managed write handle used by optional streaming callbacks

Callbacks

abort_write(writer_handle, state)

(optional)
@callback abort_write(writer_handle(), state()) :: :ok

Abort a streaming write operation.

abort_write(writer_handle, session, state)

(optional)
@callback abort_write(writer_handle(), session(), state()) :: :ok

begin_write(path, state)

(optional)
@callback begin_write(path(), state()) :: {:ok, writer_handle()} | {:error, atom()}

Begin a streaming write operation.

The returned writer handle is passed back to subsequent streaming write callbacks.

begin_write(path, session, state)

(optional)
@callback begin_write(path(), session(), state()) ::
  {:ok, writer_handle()} | {:error, atom()}

del_dir(path, state)

@callback del_dir(path(), state()) :: :ok | {:error, atom()}

Delete an empty directory.

del_dir(path, session, state)

(optional)
@callback del_dir(path(), session(), state()) :: :ok | {:error, atom()}

delete(path, state)

@callback delete(path(), state()) :: :ok | {:error, atom()}

Delete a file.

delete(path, session, state)

(optional)
@callback delete(path(), session(), state()) :: :ok | {:error, atom()}

file_info(path, state)

@callback file_info(path(), state()) :: {:ok, file_info()} | {:error, atom()}

Get file or directory information.

Returns an Erlang file_info tuple. Use Sftpd.Backend.file_info/3 or Sftpd.Backend.directory_info/0 helpers to construct these.

file_info(path, session, state)

(optional)
@callback file_info(path(), session(), state()) :: {:ok, file_info()} | {:error, atom()}

finish_write(writer_handle, state)

(optional)
@callback finish_write(writer_handle(), state()) :: :ok | {:error, atom()}

Finalize a streaming write operation.

finish_write(writer_handle, session, state)

(optional)
@callback finish_write(writer_handle(), session(), state()) :: :ok | {:error, atom()}

init(opts)

@callback init(opts :: keyword()) :: {:ok, state()} | {:error, term()}

Initialize the backend with the given options.

Called once when the SFTP session starts. Returns the initial state that will be passed to all subsequent callbacks.

list_dir(path, state)

@callback list_dir(path(), state()) :: {:ok, [charlist()]} | {:error, atom()}

List the contents of a directory.

Returns a list of filenames as charlists. Must include . and .. entries.

list_dir(path, session, state)

(optional)
@callback list_dir(path(), session(), state()) :: {:ok, [charlist()]} | {:error, atom()}

make_dir(path, state)

@callback make_dir(path(), state()) :: :ok | {:error, atom()}

Create a directory.

make_dir(path, session, state)

(optional)
@callback make_dir(path(), session(), state()) :: :ok | {:error, atom()}

read_file(path, state)

@callback read_file(path(), state()) :: {:ok, binary()} | {:error, atom()}

Read the entire contents of a file.

For large files, consider implementing streaming in your backend and using the read_file_range/4 optional callback.

read_file(path, session, state)

(optional)
@callback read_file(path(), session(), state()) :: {:ok, binary()} | {:error, atom()}

read_file_range(path, offset, len, state)

(optional)
@callback read_file_range(
  path(),
  offset :: non_neg_integer(),
  len :: pos_integer(),
  state()
) ::
  {:ok, binary()} | :eof | {:error, atom()}

Read a byte range from a file.

This is an optional callback used to avoid buffering entire files in memory. Return :eof when the requested offset is at or past the end of the file.

read_file_range(path, offset, len, session, state)

(optional)
@callback read_file_range(
  path(),
  offset :: non_neg_integer(),
  len :: pos_integer(),
  session(),
  state()
) :: {:ok, binary()} | :eof | {:error, atom()}

rename(src, dst, state)

@callback rename(src :: path(), dst :: path(), state()) :: :ok | {:error, atom()}

Rename/move a file or directory.

rename(src, dst, session, state)

(optional)
@callback rename(src :: path(), dst :: path(), session(), state()) ::
  :ok | {:error, atom()}

write_chunk(writer_handle, offset, iodata, state)

(optional)
@callback write_chunk(writer_handle(), offset :: non_neg_integer(), iodata(), state()) ::
  {:ok, writer_handle()} | {:error, atom()}

Append a chunk to a streaming write operation at the given offset.

write_chunk(writer_handle, offset, iodata, session, state)

(optional)
@callback write_chunk(
  writer_handle(),
  offset :: non_neg_integer(),
  iodata(),
  session(),
  state()
) :: {:ok, writer_handle()} | {:error, atom()}

write_file(path, content, state)

@callback write_file(path(), content :: binary(), state()) :: :ok | {:error, atom()}

Write content to a file, creating or overwriting it.

write_file(path, content, session, state)

(optional)
@callback write_file(path(), content :: binary(), session(), state()) ::
  :ok | {:error, atom()}

Functions

directory_info()

@spec directory_info() :: file_info()

Build a file_info tuple for a directory.

Returns a directory with mode 16877 (0o40755 = directory with rwxr-xr-x permissions).

file_info(size, mtime, access \\ :read_write)

@spec file_info(non_neg_integer(), :calendar.datetime(), :read | :write | :read_write) ::
  file_info()

Build a file_info tuple for a regular file.

Parameters

  • size - File size in bytes
  • mtime - Modification time as Erlang datetime tuple {{Y,M,D},{H,M,S}}
  • access - Access mode, one of :read, :write, :read_write

File Info Tuple Structure

The tuple matches Erlang's #file_info{} record:

{:file_info,
  size,           # File size in bytes
  type,           # :regular | :directory | :symlink | etc.
  access,         # :read | :write | :read_write | :none
  atime,          # Last access time {{Y,M,D},{H,M,S}}
  mtime,          # Last modification time
  ctime,          # Creation/change time
  mode,           # Unix permission bits (33188 = 0o100644 = regular file, rw-r--r--)
  links,          # Number of hard links
  major_device,   # Major device number (0 for regular files)
  minor_device,   # Minor device number (0 for regular files)
  inode,          # Inode number (random for virtual filesystems)
  uid,            # Owner user ID
  gid}            # Owner group ID

Examples

iex> {:file_info, 12, :regular, :read_write, {{2024, 1, 1}, {0, 0, 0}}, _, _, _, _, _, _, _, _, _} =
...>   Sftpd.Backend.file_info(12, {{2024, 1, 1}, {0, 0, 0}})

normalize_path(path)

@spec normalize_path(path() | String.t()) :: String.t()

Normalize an SFTP path to a string without leading slash.

Useful for backends that use string keys (like S3).

Examples

iex> Sftpd.Backend.normalize_path(~c"/folder/file.txt")
"folder/file.txt"

iex> Sftpd.Backend.normalize_path("already/normalized")
"already/normalized"

root_path?(path)

@spec root_path?(path()) :: boolean()

Return true if the path refers to the root directory.

Handles all common root path representations used by SFTP clients.

Examples

iex> Sftpd.Backend.root_path?(~c"/")
true

iex> Sftpd.Backend.root_path?(~c"/nested")
false

supports_callback?(module, function, arity)

@spec supports_callback?(module() | genserver_backend(), atom(), arity()) :: boolean()

Return true when a module backend implements the given optional callback.

Process-based backends use the legacy callback contract only.