lib/openagents/forge/repo_ref.ex

58e6347eeb72 · 6 KB

defmodule OpenAgents.Forge.RepoRef do
  @moduledoc """
  The one place a repository *name* becomes a repository *storage key*.

  Two different strings identify a repository here, and confusing them is what
  issue #190 was:

    * A **name** is what a person has. It is `openagents.com`, or the
      `owner/name` path they clone — `OpenAgentsInc/openagents.com`. It is what
      `OpenAgents.Forge.Repos.allowed_repos/0` lists, what a Git URL carries,
      and what `OpenAgents.Forge.Targets` records on a receipt. A name is not
      unique on its own: two namespaces can each own a repository called
      `docs`.
    * A **storage key** is what the forge keys durable state with. It is
      `Repository.storage_key`, it is unique, it is opaque — a UUID for every
      repository created since `REPOSITORY-001` — and it is the single path
      segment under which the WAL keeps a log
      (`OpenAgents.Forge.WAL`) and a node keeps its bare repository
      (`OpenAgents.Forge.Repos.bare_path/1`).

  Passing a name where a storage key belongs does not fail loudly. Both are
  strings, and a name is a legal path segment, so the forge builds a path out
  of it and finds an empty directory or nothing at all. That is how
  `Verification.verify("openagents.com")` came to report `wal_unreadable` for a
  repository whose log was intact, and how a bare repository under the *name*
  came to sit beside the one under the key.

  ## Resolution order

  `storage_key/1` answers in this order, and the order is the point:

    1. If the string is a legal storage key *and* the WAL holds a log under it,
       it is a storage key and it resolves to itself. This step reaches no
       database, so a verifier handed a key stays independent of PostgreSQL —
       `EXIT-002` — and a repository whose log predates the `repositories`
       table still resolves.
    2. Otherwise the string is treated as a name and looked up in
       `repositories`, by storage key, by `name`, or by the `owner/name` path
       the serving path resolves (`OpenAgents.Forge.GitHTTP`), namespace
       aliases included. Exactly one repository is an answer; two is
       `:ambiguous_name`, because guessing which of two repositories an
       operator meant is worse than saying the name is not enough.
    3. Otherwise `:repository_not_found`.

  A database that cannot be reached during step 2 is `:repository_lookup_unavailable`
  rather than "not found": a name whose answer is unknown must not be reported
  as a name with no answer.
  """

  import Ecto.Query

  alias OpenAgents.Forge.{Repos, WAL}
  alias OpenAgents.Repo
  alias OpenAgents.Repositories.{NamespaceAlias, Repository}

  @typedoc "What a person has: a repository `name`, or an `owner/name` path."
  @type name :: String.t()

  @typedoc """
  What the forge keys durable state with: one opaque, unique path segment.
  """
  @type storage_key :: String.t()

  @typedoc "Either of the two, before anyone has decided which it is."
  @type ref :: name() | storage_key()

  @typedoc "Why a reference names no single repository."
  @type resolution_error ::
          :repository_not_found | :ambiguous_name | :repository_lookup_unavailable

  @doc """
  The storage key `ref` names.

  Returns `{:ok, storage_key}`, or `{:error, reason}` where `reason` is one of
  the `t:resolution_error/0` values. See the module documentation for the
  order in which the two interpretations are tried.
  """
  @spec storage_key(term()) :: {:ok, storage_key()} | {:error, resolution_error()}
  def storage_key(ref) when is_binary(ref) do
    if wal_key?(ref) do
      {:ok, ref}
    else
      resolve_name(ref)
    end
  end

  def storage_key(_ref), do: {:error, :repository_not_found}

  @doc """
  The storage key `ref` names, or `ref` itself when nothing settles it.

  For the callers whose honest fallback is the string they were given: a mirror
  push and a cache warm both act on a bare repository that may well be keyed by
  the string itself, and neither is in a position to refuse. A caller that
  reports to a person should use `storage_key/1` instead, so an unresolved name
  is named as one rather than turned into a path.
  """
  @spec storage_key_or_ref(term()) :: term()
  def storage_key_or_ref(ref) when is_binary(ref) do
    case storage_key(ref) do
      {:ok, resolved} -> resolved
      {:error, _reason} -> ref
    end
  end

  def storage_key_or_ref(ref), do: ref

  # A log under this exact key is the only evidence that settles a string as a
  # storage key without asking the database anything.
  defp wal_key?(ref) do
    Repos.valid_storage_key?(ref) and match?({:ok, _generation, _index}, WAL.read_index(ref))
  end

  defp resolve_name(ref) do
    case candidate_keys(ref) do
      [storage_key] -> {:ok, storage_key}
      [] -> {:error, :repository_not_found}
      _two_or_more -> {:error, :ambiguous_name}
    end
  rescue
    _database_unavailable -> {:error, :repository_lookup_unavailable}
  end

  defp candidate_keys(ref) do
    case String.split(ref, "/") do
      [owner, name] -> owner |> path_query(name) |> keys()
      [_bare_name] -> ref |> bare_query() |> keys()
      _not_a_reference -> []
    end
  end

  defp keys(query) do
    query
    |> select([repository], repository.storage_key)
    |> limit(2)
    |> Repo.all()
    |> Enum.uniq()
  end

  # The same mapping the serving path resolves a clone URL through
  # (`OpenAgents.Forge.GitHTTP`), including renamed namespaces, so an operator
  # verifies the repository their `git clone` reached and not a different one.
  defp path_query(owner, name) do
    owner_key = String.downcase(owner)
    name_key = String.downcase(name)

    from repository in Repository,
      join: namespace in assoc(repository, :namespace),
      left_join: namespace_alias in NamespaceAlias,
      on: namespace_alias.namespace_id == namespace.id and namespace_alias.slug_key == ^owner_key,
      where:
        repository.name_key == ^name_key and
          (namespace.slug_key == ^owner_key or not is_nil(namespace_alias.id))
  end

  defp bare_query(ref) do
    name_key = String.downcase(ref)

    from repository in Repository,
      where: repository.storage_key == ^ref or repository.name_key == ^name_key
  end
end