Ctrl K
ComunidadGuías…

lib/audio_proxy/source/local.ex

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
defmodule AudioProxy.Source.Local do
  @moduledoc """
  The `local://` source type: files under one configured directory.

  `local://previews/track.wav` names a path relative to `AP_LOCAL_ROOT`. The
  root is deployment configuration — a bind mount, a volume, a directory on a
  laptop — and it deliberately does not appear in the source's identity, so the
  same relative path is the same variant whatever a deployment mounted it at.

  When `AP_LOCAL_ROOT` is unset, local sources are disabled: nothing is
  mounted, so nothing is served. The root *is* the allowlist for disk, which is
  why this type has no allowlist of its own.

  ## Confinement

  Every hostile *path* is refused by `authorize/1`, and refused the same way —
  `{:error, :not_allowed}`, which the HTTP layer renders as 404. Same status as
  a missing file, on purpose: a distinct status would turn the root into an
  existence oracle for the filesystem around it.

  A hostile *filesystem object* is a different question, and one `authorize/1`
  deliberately does not answer: a FIFO or a directory under the root is a
  perfectly legitimate path. Those are refused by `stat/1` and `ffmpeg_input/1`,
  which are the two functions that actually touch the file — see *The seam*.

  The order matters more than any single check:

  1. `AudioProxy.Source` has already decoded the source exactly once and
     refused control-class code points, NUL among them. A confinement check on
     a half-decoded string proves nothing, so it does not run on one.
  2. `Path.safe_relative/2` rejects absolute paths and any `..` that climbs out
     of the root, and normalizes `.` and interior `..` away.
  3. The result is joined to the root and resolved link by link, because
     `safe_relative` does not follow symlinks — a `previews` symlinked at
     `/etc` is a perfectly safe *relative* path.
  4. The resolved path must still sit under the resolved root. This is the
     invariant, and the one the property test pins: whatever is accepted, its
     final path has the root as a prefix.

  Nothing is normalized-and-continued. A path that fails any step is refused,
  not repaired. Step 2 is stricter than step 4 in one respect worth knowing
  about: OTP treats *any* absolute symlink target as unsafe, so a link
  inside the root that spells its target absolutely is refused even though
  the target is somewhere this type would happily serve. Relative links
  inside the root work; absolute ones want a root pointed at the resolved
  location instead.
  `parse/1` takes almost no part in this. It collapses empty and `.` segments —
  lexical work that is safe under any filesystem, and stops one file from
  wearing several cache keys — and otherwise hands the path through. It does
  not touch `..`, and it leaves a leading `/` in place, so an absolute path
  stays absolute and gets refused rather than quietly reinterpreted. Everything
  that needs to know what is on disk stays in `authorize/1`, in one place, run
  on every source including one a caller built by hand.

  Two bounds are enforced before any of it: at most 64 path components and
  1024 bytes. `Path.safe_relative/2` is superlinear
  in component count — a thousand components costs seconds of scheduler time on
  one process — so the cap is a denial-of-service control.

  ## The seam

  `stat/1` reads size and mtime and hashes them into ETag material.
  `ffmpeg_input/1` returns the resolved absolute path. Both re-run confinement
  rather than trusting an earlier `authorize/1`, and both refuse anything that
  is not a regular file: handing ffmpeg a FIFO is how you get a process that
  blocks forever on a read that never completes, holding a render slot until
  the timeout kills it.

  ## What confinement does not cover

  Confinement is defined over *paths*, and two things escape that definition.
  Both are deployment assumptions, not code defects, and both are documented in
  the README:

    * **Hardlinks.** A hardlink inside the root pointing at an inode outside it
      is indistinguishable from an ordinary file to any path-based check.
    * **Time of check to time of use.** `ffmpeg_input/1` returns a path, and
      ffmpeg opens it a moment later. Anything that can rewrite the root in that
      window can swap the file for a symlink. Closing this needs the render
      pipeline to pass an already-open descriptor, which is tracked with
      `add-render-endpoint`.

  Both require write access to the root, which is the assumption to hold:
  **mount it read-only, and do not let untrusted code write into it.**
  """

  @behaviour AudioProxy.Source.Type

  alias AudioProxy.Config

  @typedoc "A local source: a path relative to `AP_LOCAL_ROOT`, as written."
  @type t :: {:local, String.t()}

  # Symlink chains are followed, but not indefinitely; a cycle would otherwise
  # be an unkillable request. Well past anything a real layout needs.
  @max_link_hops 32

  # `Path.safe_relative/2` is superlinear in component count — measured on this
  # machine: 100 components 7.9 ms, 250 → 65 ms, 500 → 397 ms, 1000 → 2764 ms.
  # Without a cap, a ~2 KB path buys ~3 s of scheduler time on one process, so
  # the cap is a denial-of-service control, not tidiness. Both bounds are far
  # past any real audio layout, and a path longer than `PATH_MAX` could not be
  # opened anyway.
  @max_components 64
  @max_path_bytes 1024

  @impl true
  def scheme, do: "local"

  @impl true
  def tag, do: :local

  @impl true
  def parse(path) when is_binary(path) do
    case normalize(path) do
      "" -> {:error, :empty_path}
      normalized -> {:ok, {:local, normalized}}
    end
  end

  @impl true
  def canonical({:local, path}) when is_binary(path), do: "local://" <> path

  @impl true
  def authorize(source) do
    case resolve(source) do
      {:ok, _absolute} -> :ok
      {:error, _reason} -> {:error, :not_allowed}
    end
  end

  @impl true
  def stat(source) do
    with {:ok, absolute} <- resolve(source),
         {:ok, %File.Stat{size: size, mtime: mtime}} <- regular_stat(absolute) do
      {:ok, %{size: size, etag: etag(size, mtime)}}
    end
  end

  @impl true
  def ffmpeg_input(source) do
    # The file type is re-checked here rather than trusted from an earlier
    # `stat/1`: handing ffmpeg a FIFO is what makes it block forever on a read
    # that never completes, holding a render slot until the timeout. A caller
    # that skipped `stat/1`, or called them in the other order, must not be
    # able to cause that.
    with {:ok, absolute} <- resolve(source),
         {:ok, _stat} <- regular_stat(absolute) do
      {:ok, absolute}
    end
  end

  @doc """
  A short human-readable sentence for one of this type's own reasons.
  """
  @spec message(atom()) :: String.t()
  def message(:empty_path), do: "local source has an empty path"
  def message(:not_allowed), do: "local source is outside the configured root"

  ## Resolution

  # The one gate. Every entry point goes through it, and every failure — no
  # root configured, a traversal attempt, a symlink out of the root — is the
  # same `:not_allowed`.
  @spec resolve(t()) :: {:ok, String.t()} | {:error, :not_allowed}
  defp resolve({:local, path}) when is_binary(path) do
    with {:ok, root} <- root(),
         {:ok, relative} <- relative(path, root),
         {:ok, absolute} <- real_path(Path.join(root, relative)),
         :ok <- confined(absolute, root) do
      {:ok, absolute}
    end
  end

  defp resolve(_source), do: {:error, :not_allowed}

  # The root is resolved on every request rather than at boot: a bind mount can
  # be re-pointed under a running container, and a stale resolved root would be
  # a confinement check against a directory that is no longer there.
  defp root do
    # `/` is refused as hard as an unset root. Every relative path is trivially
    # "under" it, so confinement stops meaning anything — and the way an
    # operator arrives there is never deliberate: `AP_LOCAL_ROOT=${DIR}/` with
    # `DIR` unset expands to exactly that. Boot validation refuses it too; this
    # is the second gate, for a config written straight into `:persistent_term`.
    with root when is_binary(root) <- Config.get(:local_root),
         {:ok, resolved} <- real_path(root),
         false <- resolved == "/",
         true <- File.dir?(resolved) do
      {:ok, resolved}
    else
      _ -> {:error, :not_allowed}
    end
  end

  defp relative(path, root) do
    # A NUL would make the Path and File calls below raise, and a gate that
    # raises is a gate that returns 500. `AudioProxy.Source` already refuses
    # them; this is the belt to that suspenders, for hand-built sources.
    if path == "" or String.contains?(path, <<0>>) or oversized?(path) do
      {:error, :not_allowed}
    else
      case Path.safe_relative(path, root) do
        # "" is what `.` and `previews/..` normalize to: the root itself.
        {:ok, ""} -> {:error, :not_allowed}
        {:ok, relative} -> {:ok, relative}
        :error -> {:error, :not_allowed}
      end
    end
  end

  # Checked before `Path.safe_relative/2`, never after — the point is to not
  # hand it the input that makes it expensive. Counting is linear.
  defp oversized?(path) do
    byte_size(path) > @max_path_bytes or
      length(String.split(path, "/")) > @max_components
  end

  # Lexical only, and deliberately blind to `..`: collapsing `a/b/..` here would
  # mean identity depended on a *guess* about what the filesystem looks like,
  # and `..` is `authorize/1`'s business anyway. Dropping empty and `.` segments
  # is safe under any filesystem, and stops one file from wearing several cache
  # keys (`a//b`, `a/./b`, `a/b`).
  #
  # A leading `/` survives, so an absolute path stays absolute and is refused
  # rather than silently reinterpreted as a relative one.
  defp normalize("/" <> _rest = path), do: path

  defp normalize(path) do
    path
    |> String.split("/")
    |> Enum.reject(&(&1 == "" or &1 == "."))
    |> Enum.join("/")
  end

  # Resolves an absolute path component by component, following any symlink it
  # meets. A component that does not exist is kept as written — `stat/1`, not
  # this function, is what answers "is it there".
  defp real_path(path), do: real_path(Path.split(Path.expand(path)), "/", @max_link_hops)

  defp real_path(_components, _resolved, 0), do: {:error, :not_allowed}
  defp real_path([], resolved, _hops), do: {:ok, resolved}
  defp real_path(["/" | rest], _resolved, hops), do: real_path(rest, "/", hops)

  defp real_path([component | rest], resolved, hops) do
    candidate = Path.join(resolved, component)

    case File.read_link(candidate) do
      {:ok, target} ->
        # Restart from the root: the target may itself be absolute, or climb
        # through further links of its own.
        target
        |> Path.expand(resolved)
        |> Path.split()
        |> Kernel.++(rest)
        |> real_path("/", hops - 1)

      # The only two answers that mean "carry on": `:einval` is "not a symlink",
      # `:enoent` is a component that does not exist yet — `stat/1` answers that,
      # not this function.
      {:error, errno} when errno in [:einval, :enoent] ->
        real_path(rest, candidate, hops)

      # Anything else (`:eacces` on an unreadable parent, `:eloop`, `:enotdir`)
      # means we could not determine whether this component is a symlink. A
      # confinement check on a path we failed to resolve is not a check, so it
      # is refused instead of assumed safe.
      {:error, _undetermined} ->
        {:error, :not_allowed}
    end
  end

  # Compared segment-wise rather than as strings, so that `/dataxxx` is not
  # read as living under `/data` and a root of `/` needs no special case.
  defp confined(absolute, root) do
    if absolute != root and List.starts_with?(Path.split(absolute), Path.split(root)) do
      :ok
    else
      {:error, :not_allowed}
    end
  end

  # Regular files only. A directory, FIFO, socket or device under the root is
  # not something to hand an encoder, and is reported as absent rather than as
  # a distinct status — same no-oracle rule as everything else here.
  defp regular_stat(absolute) do
    case File.stat(absolute, time: :posix) do
      {:ok, %File.Stat{type: :regular} = stat} -> {:ok, stat}
      _other -> {:error, :not_found}
    end
  end

  # Size and mtime, not content: an ETag is a version marker, and hashing the
  # bytes of a multi-gigabyte master to answer `/info` would defeat the point.
  defp etag(size, mtime) do
    :sha256
    |> :crypto.hash("#{size}-#{mtime}")
    |> Base.encode16(case: :lower)
    |> binary_part(0, 32)
  end
end