4.6 KiB · text History 69f26e0
defmodule GitGud.Packages.Formats.Tar do
  @moduledoc """
  Lists a tar archive held in memory: each member's path and either its
  bytes or where it links to.

  `:erl_tar` can extract to memory, but it drops symlinks on the floor,
  and an icon theme is mostly symlinks — `/usr/share/pixmaps/foo.png`
  pointing into `hicolor/` is the common case, not the odd one. So
  this reads the headers itself, which is 512 bytes of fixed-width
  ASCII a member.

  Members come back as sub-binaries of the archive, so listing a
  200 MB `data.tar` copies nothing.

  Understood: ustar, GNU long names and long links (`L`/`K`), and pax
  `path`/`linkpath` records — between them, everything `dpkg-deb`,
  `cargo-deb` and `tar` write. Paths are normalised to absolute
  (`./usr/bin/x` and `usr/bin/x` both become `/usr/bin/x`).
  """

  @type entry :: {:file, binary()} | {:symlink, String.t()} | {:hardlink, String.t()}

  @doc "Every file and link in `tar`, keyed by absolute path."
  @spec entries(binary()) :: {:ok, %{String.t() => entry()}} | :error
  def entries(tar) when is_binary(tar), do: read(tar, %{}, %{})

  defp read(<<header::binary-size(512), rest::binary>>, pending, acc) do
    if header == <<0::size(512)-unit(8)>> do
      {:ok, acc}
    else
      member(header, rest, pending, acc)
    end
  end

  # Archives that end without the two zero blocks are still readable up
  # to where they stop.
  defp read(_rest, _pending, acc), do: {:ok, acc}

  defp member(header, rest, pending, acc) do
    <<name::binary-size(100), _mode::binary-size(8), _uid::binary-size(8), _gid::binary-size(8),
      size::binary-size(12), _mtime::binary-size(12), _checksum::binary-size(8), type,
      linkname::binary-size(100), magic::binary-size(6), _version::binary-size(2),
      _uname::binary-size(32), _gname::binary-size(32), _devmajor::binary-size(8),
      _devminor::binary-size(8), prefix::binary-size(155), _::binary>> = header

    with {:ok, size} <- size(size),
         true <- byte_size(rest) >= size || :error do
      data = binary_part(rest, 0, size)
      skip = min(size + rem(512 - rem(size, 512), 512), byte_size(rest))
      rest = binary_part(rest, skip, byte_size(rest) - skip)

      case type do
        ?L ->
          read(rest, Map.put(pending, :path, cstring(data)), acc)

        ?K ->
          read(rest, Map.put(pending, :linkpath, cstring(data)), acc)

        ?x ->
          read(rest, Map.merge(pending, pax(data)), acc)

        _ ->
          path = pending[:path] || joined(magic, prefix, cstring(name))
          link = pending[:linkpath] || cstring(linkname)
          read(rest, %{}, add(acc, type, normalize(path), data, link))
      end
    end
  end

  # Directories, devices and fifos carry nothing an icon lookup wants.
  defp add(acc, type, path, data, _link) when type in [?0, 0, ?7],
    do: Map.put(acc, path, {:file, data})

  defp add(acc, ?2, path, _data, link), do: Map.put(acc, path, {:symlink, link})
  defp add(acc, ?1, path, _data, link), do: Map.put(acc, path, {:hardlink, normalize(link)})
  defp add(acc, _type, _path, _data, _link), do: acc

  # POSIX ustar splits a long path across `prefix` and `name`; GNU tar
  # writes "ustar  " and uses those bytes for something else.
  defp joined("ustar" <> <<0>>, prefix, name) do
    case cstring(prefix) do
      "" -> name
      prefix -> prefix <> "/" <> name
    end
  end

  defp joined(_magic, _prefix, name), do: name

  # Octal ASCII, NUL- or space-terminated — or, for members over 8 GB,
  # big-endian binary flagged by the high bit of the first byte.
  defp size(<<1::1, _::7, _::binary>> = raw) do
    <<_::1, n::size(95)>> = raw
    {:ok, n}
  end

  defp size(raw) do
    case raw |> cstring() |> String.trim() |> Integer.parse(8) do
      {n, ""} -> {:ok, n}
      _ when raw == <<0::96>> -> {:ok, 0}
      _ -> :error
    end
  end

  # pax records: "<len> <key>=<value>\n", repeated.
  defp pax(data) do
    data
    |> String.split("\n", trim: true)
    |> Enum.reduce(%{}, fn record, acc ->
      with [_len, kv] <- String.split(record, " ", parts: 2),
           [key, value] <- String.split(kv, "=", parts: 2) do
        case key do
          "path" -> Map.put(acc, :path, value)
          "linkpath" -> Map.put(acc, :linkpath, value)
          _ -> acc
        end
      else
        _ -> acc
      end
    end)
  end

  defp cstring(bytes) do
    case :binary.match(bytes, <<0>>) do
      {at, _} -> binary_part(bytes, 0, at)
      :nomatch -> bytes
    end
  end

  @doc false
  def normalize(path) do
    "/" <>
      (path
       |> String.split("/", trim: true)
       |> Enum.reject(&(&1 == "."))
       |> Enum.join("/"))
  end
end