lib/openagents/forge/build_artifact.ex

58e6347eeb72 · 25 KB

defmodule OpenAgents.Forge.BuildArtifact do
  @moduledoc """
  Reproducible forge BEAM artifact creation and atom-free verification.

  Artifacts are deterministic tar files containing one canonical
  `manifest.json` and only the added or changed normalized BEAMs. Verification
  binds the tar digest, manifest identity, entry names, sizes, module count,
  BEAM-internal module identity, baseline, and structural classification
  before a caller may turn any module name into an atom.
  """

  alias OpenAgents.Forge.BuildProtocol
  alias OpenAgents.Forge.WAL

  @schema "openagents.forge.build-artifact.v1"
  @max_artifact_bytes 32 * 1_048_576
  @max_manifest_bytes 1_048_576
  @max_beam_bytes 4 * 1_048_576
  @max_modules 2_048
  @manifest_keys ~w(schema build_id repo source_sha baseline toolchain classification structural_reasons changes modules)
  @toolchain_keys ~w(elixir otp erts application_version application_spec_sha256 mix_lock_sha256)
  @change_keys ~w(added changed deleted)
  @module_keys ~w(name sha256 size)
  @classification ~w(direct_candidate needs_rolling_replace)
  # Keep atom creation bounded to module namespaces owned by this OTP
  # application. The release contains the two application roots, generated
  # protocol implementations (Inspect, Jason.Encoder, and String.Chars) for
  # application structs, and Mix tasks in addition to OpenAgents.*.
  @module_pattern ~r/^Elixir\.(?:(?:OpenAgents|OpenAgentsWeb)(?:\.[A-Za-z][A-Za-z0-9_]*)*|(?:Inspect|Jason\.Encoder|String\.Chars)\.(?:OpenAgents|OpenAgentsWeb)(?:\.[A-Za-z][A-Za-z0-9_]*)+|Mix\.Tasks\.(?:OpenAgents|Openagents)(?:\.[A-Za-z][A-Za-z0-9_]*)+)$/

  @type beam :: %{module: String.t(), binary: binary()}
  @type verified :: %{
          digest: String.t(),
          manifest: map(),
          beams: [beam()],
          modules: [String.t()]
        }

  @doc "Normalize BEAM bytes by retaining only OTP-defined significant chunks."
  @spec normalize_beam(binary()) :: {:ok, binary()} | {:error, term()}
  def normalize_beam(binary) when is_binary(binary) and byte_size(binary) <= @max_beam_bytes do
    case :beam_lib.strip(binary) do
      {:ok, {_module, normalized}} when byte_size(normalized) <= @max_beam_bytes ->
        {:ok, normalized}

      {:ok, {_module, _too_large}} ->
        {:error, :beam_too_large}

      {:error, _module, reason} ->
        {:error, {:invalid_beam, reason}}
    end
  rescue
    _error -> {:error, :invalid_beam}
  end

  def normalize_beam(_binary), do: {:error, :beam_too_large}

  @doc "Current compiler/runtime identity recorded in every build manifest."
  @spec current_toolchain(keyword()) :: map()
  def current_toolchain(opts \\ []) do
    lock_path = Keyword.get(opts, :lock_path, "mix.lock")
    app_file = Keyword.get(opts, :app_file)

    %{
      "elixir" => System.version(),
      "otp" => System.otp_release(),
      "erts" => to_string(:erlang.system_info(:version)),
      "application_version" => application_version(app_file),
      "application_spec_sha256" => application_spec_digest(app_file),
      "mix_lock_sha256" => file_digest(lock_path)
    }
  end

  @doc "Create a deterministic verified artifact from the full candidate module set."
  @spec pack(String.t(), String.t(), String.t(), [beam()], keyword()) ::
          {:ok, %{bytes: binary(), digest: String.t(), manifest: map(), beams: [beam()]}}
          | {:error, term()}
  def pack(repo, source_sha, build_id, candidate_beams, opts \\ []) do
    baseline = Keyword.get(opts, :baseline_manifest)
    toolchain = Keyword.get(opts, :toolchain, current_toolchain())
    structural_reasons = Keyword.get(opts, :structural_reasons, [])

    with :ok <- WAL.validate_repo(repo),
         :ok <- validate_sha(source_sha),
         :ok <- validate_uuid(build_id),
         {:ok, normalized} <- normalize_candidates(candidate_beams),
         {:ok, baseline_modules} <- baseline_modules(baseline),
         {:ok, toolchain} <- validate_toolchain(toolchain),
         {:ok, manifest, changed_beams} <-
           build_manifest(
             repo,
             source_sha,
             build_id,
             normalized,
             baseline,
             baseline_modules,
             toolchain,
             structural_reasons
           ),
         {:ok, bytes} <- create_tar(manifest, changed_beams),
         digest = digest(bytes),
         {:ok, verified} <-
           verify(bytes,
             digest: digest,
             repo: repo,
             source_sha: source_sha,
             build_id: build_id
           ) do
      {:ok,
       %{
         bytes: bytes,
         digest: digest,
         manifest: manifest,
         beams: verified.beams
       }}
    end
  end

  @doc "Verify an artifact without creating atoms."
  @spec verify(binary(), keyword()) :: {:ok, verified()} | {:error, term()}
  def verify(bytes, opts \\ [])

  def verify(bytes, opts) when is_binary(bytes) do
    expected_digest = Keyword.get(opts, :digest)

    with :ok <- validate_artifact_size(bytes),
         actual_digest = digest(bytes),
         :ok <- match_expected(actual_digest, expected_digest, :artifact_digest_mismatch),
         {:ok, entries} <- extract_entries(bytes),
         {:ok, manifest_bytes, beam_entries} <- split_entries(entries),
         {:ok, manifest} <- decode_manifest(manifest_bytes),
         :ok <- validate_manifest(manifest),
         :ok <- validate_expected_identity(manifest, opts),
         {:ok, beams} <- verify_beams(beam_entries, manifest),
         :ok <- verify_change_entries(beams, manifest) do
      {:ok,
       %{
         digest: actual_digest,
         manifest: manifest,
         beams: beams,
         modules: Enum.map(beams, & &1.module)
       }}
    end
  end

  def verify(_bytes, _opts), do: {:error, :invalid_artifact}

  @doc "Read and verify a local artifact."
  @spec verify_file(Path.t(), keyword()) :: {:ok, verified()} | {:error, term()}
  def verify_file(path, opts \\ []) do
    with {:ok, bytes} <- File.read(path), do: verify(bytes, opts)
  end

  @doc "Return the module identity embedded in a BEAM without creating an atom."
  @spec beam_module(binary()) :: {:ok, String.t()} | {:error, term()}
  def beam_module(binary) when is_binary(binary) do
    with {:ok, beam} <- maybe_gunzip(binary),
         {:ok, chunks} <- beam_chunks(beam),
         {:ok, atom_chunk, encoding} <- atom_chunk(chunks),
         {:ok, module} <- first_atom(atom_chunk, encoding),
         true <- valid_module?(module) or {:error, :invalid_module_name} do
      {:ok, module}
    else
      false -> {:error, :invalid_beam_identity}
      {:error, _reason} = error -> error
    end
  rescue
    _error -> {:error, :invalid_beam_identity}
  end

  @doc "Create a module atom only after `verify/2` has accepted the full artifact."
  @spec module_atom(String.t()) :: atom()
  def module_atom(module) when is_binary(module) do
    if valid_module?(module),
      do: String.to_atom(module),
      else: raise(ArgumentError, "invalid verified module name")
  end

  @doc "SHA-256 digest as lowercase hexadecimal."
  def digest(bytes) when is_binary(bytes) do
    :sha256
    |> :crypto.hash(bytes)
    |> Base.encode16(case: :lower)
  end

  defp normalize_candidates(beams) when is_list(beams) and length(beams) <= @max_modules do
    beams
    |> Enum.reduce_while({:ok, %{}}, fn
      %{module: module, binary: binary}, {:ok, acc}
      when is_binary(module) and is_binary(binary) ->
        with true <- valid_module?(module) or {:error, :invalid_module_name},
             true <- not Map.has_key?(acc, module) or {:error, :duplicate_module},
             {:ok, normalized} <- normalize_beam(binary),
             {:ok, ^module} <- beam_module(normalized) do
          {:cont, {:ok, Map.put(acc, module, normalized)}}
        else
          {:error, _reason} = error -> {:halt, error}
          _other -> {:halt, {:error, :beam_module_mismatch}}
        end

      _beam, _acc ->
        {:halt, {:error, :invalid_beam_entry}}
    end)
  end

  defp normalize_candidates(_beams), do: {:error, :too_many_modules}

  defp build_manifest(
         repo,
         source_sha,
         build_id,
         normalized,
         baseline,
         baseline_modules,
         toolchain,
         structural_reasons
       ) do
    current_modules = Map.new(normalized, fn {module, binary} -> {module, digest(binary)} end)
    added = current_modules |> Map.keys() |> Enum.reject(&Map.has_key?(baseline_modules, &1))

    changed =
      current_modules
      |> Enum.filter(fn {module, module_digest} ->
        match?(
          %{^module => baseline_digest} when baseline_digest != module_digest,
          baseline_modules
        )
      end)
      |> Enum.map(&elem(&1, 0))

    deleted = baseline_modules |> Map.keys() |> Enum.reject(&Map.has_key?(current_modules, &1))

    toolchain_reasons = toolchain_reasons(baseline, toolchain)

    reasons =
      structural_reasons
      |> Enum.map(&to_string/1)
      |> Kernel.++(if(deleted == [], do: [], else: ["module_deletion"]))
      |> Kernel.++(toolchain_reasons)
      |> Enum.uniq()
      |> Enum.sort()

    classification = if reasons == [], do: "direct_candidate", else: "needs_rolling_replace"

    module_entries =
      normalized
      |> Enum.map(fn {module, binary} ->
        %{"name" => module, "sha256" => digest(binary), "size" => byte_size(binary)}
      end)
      |> Enum.sort_by(& &1["name"])

    manifest = %{
      "schema" => @schema,
      "build_id" => build_id,
      "repo" => repo,
      "source_sha" => source_sha,
      "baseline" => baseline_identity(baseline),
      "toolchain" => toolchain,
      "classification" => classification,
      "structural_reasons" => reasons,
      "changes" => %{
        "added" => Enum.sort(added),
        "changed" => Enum.sort(changed),
        "deleted" => Enum.sort(deleted)
      },
      "modules" => module_entries
    }

    changed_names = MapSet.new(added ++ changed)

    changed_beams =
      normalized
      |> Enum.filter(fn {module, _binary} -> MapSet.member?(changed_names, module) end)
      |> Enum.map(fn {module, binary} -> %{module: module, binary: binary} end)
      |> Enum.sort_by(& &1.module)

    {:ok, manifest, changed_beams}
  end

  defp create_tar(manifest, beams) do
    manifest_json = BuildProtocol.canonical_json(manifest)

    entries =
      [{~c"manifest.json", manifest_json}] ++
        Enum.map(beams, fn %{module: module, binary: binary} ->
          {String.to_charlist("beams/" <> module <> ".beam"), binary}
        end)

    path =
      Path.join(
        System.tmp_dir!(),
        "openagents-artifact-" <> Base.url_encode64(:crypto.strong_rand_bytes(12), padding: false)
      )

    try do
      with :ok <- write_reproducible_tar(path, entries),
           {:ok, bytes} <- File.read(path),
           :ok <- validate_artifact_size(bytes) do
        {:ok, bytes}
      end
    after
      File.rm(path)
    end
  end

  defp write_reproducible_tar(path, entries) do
    case :erl_tar.open(String.to_charlist(path), [:write]) do
      {:ok, tar} ->
        write_result =
          Enum.reduce_while(entries, :ok, fn entry, :ok ->
            case :erl_tar.add(tar, entry,
                   mtime: 0,
                   atime: 0,
                   ctime: 0,
                   uid: 0,
                   gid: 0
                 ) do
              :ok -> {:cont, :ok}
              {:error, reason} -> {:halt, {:error, reason}}
            end
          end)

        close_result = :erl_tar.close(tar)

        case {write_result, close_result} do
          {:ok, :ok} -> :ok
          {{:error, reason}, _close} -> {:error, reason}
          {:ok, {:error, reason}} -> {:error, reason}
        end

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

  defp extract_entries(bytes) do
    case :erl_tar.extract({:binary, bytes}, [:memory]) do
      {:ok, entries} when length(entries) <= @max_modules + 1 ->
        normalized = Enum.map(entries, fn {name, value} -> {to_string(name), value} end)

        if Enum.uniq_by(normalized, &elem(&1, 0)) == normalized,
          do: {:ok, normalized},
          else: {:error, :duplicate_artifact_entry}

      {:ok, _entries} ->
        {:error, :too_many_artifact_entries}

      {:error, reason} ->
        {:error, {:invalid_tar, reason}}
    end
  rescue
    _error -> {:error, :invalid_tar}
  end

  defp split_entries(entries) do
    case Enum.split_with(entries, fn {name, _value} -> name == "manifest.json" end) do
      {[{"manifest.json", manifest}], beam_entries}
      when byte_size(manifest) <= @max_manifest_bytes ->
        if Enum.all?(beam_entries, fn {name, binary} ->
             valid_beam_path?(name) and is_binary(binary) and byte_size(binary) <= @max_beam_bytes
           end) do
          {:ok, manifest, beam_entries}
        else
          {:error, :invalid_artifact_entry}
        end

      {[_manifest], _beam_entries} ->
        {:error, :manifest_too_large}

      _other ->
        {:error, :manifest_count}
    end
  end

  defp decode_manifest(bytes) do
    with {:ok, manifest} <- Jason.decode(bytes),
         true <-
           BuildProtocol.canonical_json(manifest) == bytes or {:error, :noncanonical_manifest} do
      {:ok, manifest}
    else
      {:error, _reason} = error -> error
      _other -> {:error, :invalid_manifest_json}
    end
  end

  defp validate_manifest(%{} = manifest) do
    with :ok <- exact_keys(manifest, @manifest_keys),
         true <- manifest["schema"] == @schema or {:error, :invalid_manifest_schema},
         :ok <- validate_uuid(manifest["build_id"]),
         :ok <- WAL.validate_repo(manifest["repo"]),
         :ok <- validate_sha(manifest["source_sha"]),
         :ok <- validate_baseline_identity(manifest["baseline"]),
         {:ok, _toolchain} <- validate_toolchain(manifest["toolchain"]),
         true <-
           manifest["classification"] in @classification or
             {:error, :invalid_classification},
         :ok <- validate_reasons(manifest["structural_reasons"]),
         :ok <- validate_changes(manifest["changes"]),
         :ok <- validate_modules(manifest["modules"]),
         :ok <- validate_classification(manifest) do
      :ok
    else
      false -> {:error, :invalid_manifest}
      {:error, _reason} = error -> error
    end
  end

  defp validate_manifest(_manifest), do: {:error, :invalid_manifest}

  defp verify_beams(entries, manifest) do
    metadata = Map.new(manifest["modules"], &{&1["name"], &1})

    entries
    |> Enum.reduce_while({:ok, []}, fn {path, binary}, {:ok, acc} ->
      module = path |> String.replace_prefix("beams/", "") |> String.replace_suffix(".beam", "")

      with %{} = declared <- metadata[module] || {:error, :undeclared_module},
           true <- declared["size"] == byte_size(binary) or {:error, :beam_size_mismatch},
           true <- declared["sha256"] == digest(binary) or {:error, :beam_digest_mismatch},
           {:ok, embedded} <- beam_module(binary),
           true <- embedded == module or {:error, :beam_module_mismatch} do
        {:cont, {:ok, [%{module: module, binary: binary} | acc]}}
      else
        {:error, _reason} = error -> {:halt, error}
        _other -> {:halt, {:error, :invalid_beam_entry}}
      end
    end)
    |> case do
      {:ok, beams} -> {:ok, Enum.sort_by(beams, & &1.module)}
      error -> error
    end
  end

  defp verify_change_entries(beams, manifest) do
    expected = Enum.sort(manifest["changes"]["added"] ++ manifest["changes"]["changed"])
    actual = Enum.map(beams, & &1.module)
    if actual == expected, do: :ok, else: {:error, :artifact_change_set_mismatch}
  end

  defp validate_expected_identity(manifest, opts) do
    checks = [
      {"repo", Keyword.get(opts, :repo)},
      {"source_sha", Keyword.get(opts, :source_sha)},
      {"build_id", Keyword.get(opts, :build_id)}
    ]

    Enum.reduce_while(checks, :ok, fn
      {_field, nil}, :ok ->
        {:cont, :ok}

      {field, expected}, :ok ->
        if manifest[field] == expected,
          do: {:cont, :ok},
          else: {:halt, {:error, {:manifest_identity_mismatch, field}}}
    end)
  end

  defp baseline_modules(nil), do: {:ok, %{}}

  defp baseline_modules(%{} = manifest) do
    with :ok <- validate_manifest(manifest) do
      {:ok, Map.new(manifest["modules"], &{&1["name"], &1["sha256"]})}
    end
  end

  defp baseline_modules(_manifest), do: {:error, :invalid_baseline_manifest}

  defp baseline_identity(nil), do: nil

  defp baseline_identity(manifest) do
    %{
      "build_id" => manifest["build_id"],
      "source_sha" => manifest["source_sha"],
      "manifest_sha256" => digest(BuildProtocol.canonical_json(manifest))
    }
  end

  defp validate_baseline_identity(nil), do: :ok

  defp validate_baseline_identity(%{} = identity) do
    with :ok <- exact_keys(identity, ~w(build_id source_sha manifest_sha256)),
         :ok <- validate_uuid(identity["build_id"]),
         :ok <- validate_sha(identity["source_sha"]),
         :ok <- validate_digest(identity["manifest_sha256"]) do
      :ok
    end
  end

  defp validate_baseline_identity(_identity), do: {:error, :invalid_baseline_identity}

  defp toolchain_reasons(nil, _toolchain), do: ["baseline_missing"]

  defp toolchain_reasons(%{"toolchain" => baseline}, toolchain) do
    @toolchain_keys
    |> Enum.reject(fn key -> baseline[key] == toolchain[key] end)
    |> Enum.map(&"toolchain_#{&1}_changed")
  end

  defp toolchain_reasons(_baseline, _toolchain), do: ["baseline_invalid"]

  defp validate_toolchain(%{} = toolchain) do
    with :ok <- exact_keys(toolchain, @toolchain_keys),
         true <-
           Enum.all?(@toolchain_keys, &valid_identity_value?(toolchain[&1])) or
             {:error, :invalid_toolchain} do
      {:ok, toolchain}
    end
  end

  defp validate_toolchain(_toolchain), do: {:error, :invalid_toolchain}

  defp valid_identity_value?(value),
    do: is_binary(value) and byte_size(value) in 1..256 and String.valid?(value)

  defp validate_changes(%{} = changes) do
    with :ok <- exact_keys(changes, @change_keys),
         true <-
           Enum.all?(@change_keys, &valid_module_list?(changes[&1])) or
             {:error, :invalid_changes},
         all = Enum.flat_map(@change_keys, &changes[&1]),
         true <- length(all) == length(Enum.uniq(all)) or {:error, :overlapping_changes} do
      :ok
    end
  end

  defp validate_changes(_changes), do: {:error, :invalid_changes}

  defp validate_modules(modules) when is_list(modules) and length(modules) <= @max_modules do
    result =
      Enum.reduce_while(modules, {:ok, []}, fn
        %{} = module, {:ok, names} ->
          with :ok <- exact_keys(module, @module_keys),
               true <- valid_module?(module["name"]) or {:error, :invalid_module_name},
               :ok <- validate_digest(module["sha256"]),
               true <-
                 (is_integer(module["size"]) and module["size"] in 1..@max_beam_bytes) or
                   {:error, :invalid_module_size} do
            {:cont, {:ok, [module["name"] | names]}}
          else
            {:error, _reason} = error -> {:halt, error}
          end

        _module, _acc ->
          {:halt, {:error, :invalid_module_metadata}}
      end)

    case result do
      {:ok, names} ->
        sorted = Enum.sort(names)

        cond do
          names != Enum.reverse(sorted) ->
            # The reduce accumulates in reverse, so canonical source order is
            # the reverse of the accumulated list.
            {:error, :unsorted_modules}

          length(names) != length(Enum.uniq(names)) ->
            {:error, :duplicate_module}

          true ->
            :ok
        end

      error ->
        error
    end
  end

  defp validate_modules(_modules), do: {:error, :too_many_modules}

  defp validate_reasons(reasons) when is_list(reasons) and length(reasons) <= 64 do
    if Enum.all?(reasons, fn reason ->
         is_binary(reason) and byte_size(reason) in 1..128 and
           Regex.match?(~r/^[a-z0-9_]+$/, reason)
       end) and reasons == Enum.sort(Enum.uniq(reasons)) do
      :ok
    else
      {:error, :invalid_structural_reasons}
    end
  end

  defp validate_reasons(_reasons), do: {:error, :invalid_structural_reasons}

  defp validate_classification(manifest) do
    expected =
      if manifest["structural_reasons"] == [],
        do: "direct_candidate",
        else: "needs_rolling_replace"

    if manifest["classification"] == expected,
      do: :ok,
      else: {:error, :classification_mismatch}
  end

  defp valid_module_list?(list) when is_list(list) and length(list) <= @max_modules do
    Enum.all?(list, &valid_module?/1) and list == Enum.sort(Enum.uniq(list))
  end

  defp valid_module_list?(_list), do: false

  defp valid_module?(module) when is_binary(module) and byte_size(module) <= 255,
    do: Regex.match?(@module_pattern, module)

  defp valid_module?(_module), do: false

  defp valid_beam_path?("beams/" <> rest = path) do
    module = String.replace_suffix(rest, ".beam", "")

    String.ends_with?(rest, ".beam") and path == "beams/" <> module <> ".beam" and
      valid_module?(module)
  end

  defp valid_beam_path?(_path), do: false

  defp validate_artifact_size(bytes) when byte_size(bytes) in 1..@max_artifact_bytes, do: :ok
  defp validate_artifact_size(_bytes), do: {:error, :artifact_too_large}

  defp validate_sha(value) when is_binary(value) do
    if Regex.match?(~r/^[0-9a-f]{40}$/, value), do: :ok, else: {:error, :invalid_source_sha}
  end

  defp validate_sha(_value), do: {:error, :invalid_source_sha}

  defp validate_uuid(value) when is_binary(value) do
    case Ecto.UUID.cast(value) do
      {:ok, _uuid} -> :ok
      :error -> {:error, :invalid_build_id}
    end
  end

  defp validate_uuid(_value), do: {:error, :invalid_build_id}

  defp validate_digest(value) when is_binary(value) do
    if Regex.match?(~r/^[0-9a-f]{64}$/, value), do: :ok, else: {:error, :invalid_digest}
  end

  defp validate_digest(_value), do: {:error, :invalid_digest}

  defp match_expected(_actual, nil, _reason), do: :ok
  defp match_expected(actual, actual, _reason), do: :ok
  defp match_expected(_actual, _expected, reason), do: {:error, reason}

  defp exact_keys(map, allowed) do
    if Map.keys(map) |> Enum.sort() == Enum.sort(allowed),
      do: :ok,
      else: {:error, :unexpected_manifest_fields}
  end

  defp maybe_gunzip(<<31, 139, _rest::binary>> = binary) do
    uncompressed = :zlib.gunzip(binary)

    if byte_size(uncompressed) <= @max_beam_bytes * 2,
      do: {:ok, uncompressed},
      else: {:error, :beam_uncompressed_too_large}
  rescue
    _error -> {:error, :invalid_gzip_beam}
  end

  defp maybe_gunzip(binary), do: {:ok, binary}

  defp beam_chunks(<<"FOR1", declared::32-big, "BEAM", rest::binary>>)
       when declared == byte_size(rest) + 4 do
    parse_chunks(rest, %{})
  end

  defp beam_chunks(_binary), do: {:error, :invalid_beam_container}

  defp parse_chunks(<<>>, chunks), do: {:ok, chunks}

  defp parse_chunks(<<id::binary-size(4), size::32-big, rest::binary>>, chunks)
       when size <= @max_beam_bytes do
    if Map.has_key?(chunks, id) do
      {:error, :duplicate_beam_chunk}
    else
      padded = size + rem(4 - rem(size, 4), 4)
      padding_size = padded - size

      if byte_size(rest) >= padded do
        <<chunk::binary-size(^size), _padding::binary-size(^padding_size), tail::binary>> = rest
        parse_chunks(tail, Map.put(chunks, id, chunk))
      else
        {:error, :truncated_beam_chunk}
      end
    end
  end

  defp parse_chunks(_rest, _chunks), do: {:error, :invalid_beam_chunk}

  defp atom_chunk(%{"AtU8" => chunk}), do: {:ok, chunk, :utf8}
  defp atom_chunk(%{"Atom" => chunk}), do: {:ok, chunk, :latin1}
  defp atom_chunk(_chunks), do: {:error, :missing_atom_chunk}

  defp first_atom(<<count::32-signed-big, rest::binary>>, :utf8) when count < 0 do
    with {:ok, length, atoms} <- compact_length(rest),
         true <- length in 1..255 or {:error, :invalid_atom_length},
         <<name::binary-size(^length), _tail::binary>> <- atoms,
         true <- String.valid?(name) or {:error, :invalid_utf8_atom} do
      {:ok, name}
    else
      {:error, _reason} = error -> error
      _other -> {:error, :truncated_atom_chunk}
    end
  end

  defp first_atom(<<count::32-signed-big, length, rest::binary>>, encoding) when count > 0 do
    with true <- length in 1..255 or {:error, :invalid_atom_length},
         <<name::binary-size(^length), _tail::binary>> <- rest do
      case encoding do
        :utf8 -> if String.valid?(name), do: {:ok, name}, else: {:error, :invalid_utf8_atom}
        :latin1 -> {:ok, :unicode.characters_to_binary(name, :latin1, :utf8)}
      end
    else
      {:error, _reason} = error -> error
      _other -> {:error, :truncated_atom_chunk}
    end
  end

  defp first_atom(_chunk, _encoding), do: {:error, :empty_atom_table}

  # OTP 28 long atom-table length encoding. Atom lengths need only the two
  # compact unsigned forms (0..2047), matching beam_lib's own bounded decoder.
  defp compact_length(<<high::4, 0::1, _tag::3, rest::binary>>), do: {:ok, high, rest}

  defp compact_length(<<high::3, 0::1, 1::1, _tag::3, low, rest::binary>>),
    do: {:ok, Bitwise.bor(Bitwise.bsl(high, 8), low), rest}

  defp compact_length(_binary), do: {:error, :invalid_atom_length_encoding}

  defp application_version(nil) do
    case Application.spec(:openagents, :vsn) do
      nil -> "unknown"
      value -> to_string(value)
    end
  end

  defp application_version(app_file) do
    with {:ok, contents} <- File.read(app_file),
         [version] <- Regex.run(~r/\{vsn,"([^"]{1,128})"\}/, contents, capture: :all_but_first) do
      version
    else
      _missing_or_invalid -> "unknown"
    end
  end

  defp application_spec_digest(nil) do
    case :code.where_is_file(~c"openagents.app") do
      :non_existing -> digest("missing")
      path -> file_digest(to_string(path))
    end
  end

  defp application_spec_digest(path), do: file_digest(path)

  defp file_digest(path) do
    case File.read(path) do
      {:ok, bytes} -> digest(bytes)
      {:error, _reason} -> digest("missing:" <> to_string(path))
    end
  end
end