lib/ex_nar.ex

defmodule ExNar do
  @moduledoc """
  Serialize or deserialize a Nix Archive
  """

  import Bitwise

  @executable_perms 0o755
  @default_perms 0o644

  # Serialize
  defp executable?(%{mode: mode}), do: (mode &&& 0o111) != 0

  defp serialize_entry(fso) do
    type = File.lstat!(fso).type
    name = Path.basename(fso)
    ["entry", "(", "name", name, "node", serialize_p(fso, type), ")"]
  end

  defp serialize_p_p(fso, :regular) do
    executable = executable?(File.lstat!(fso))
    executable = if executable, do: ["executable", ""], else: []
    ["regular", executable, "contents", File.read!(fso)]
  end

  defp serialize_p_p(fso, :bytestream) do
    ["regular", "contents", fso]
  end

  defp serialize_p_p(fso, :symlink) do
    ["symlink", "target", File.read_link!(fso)]
  end

  defp serialize_p_p(fso, :directory) do
    sorted_entries = File.ls!(fso) |> Enum.sort()
    ["directory", Enum.map(sorted_entries, &serialize_entry(Path.join(fso, &1)))]
  end

  defp serialize_p(fso, type) do
    ["(", "type", serialize_p_p(fso, type), ")"]
  end

  # Pads a byte sequence `s` with 0s to a multiple of 8 bytes
  defp pad(s) do
    pad = 8 - rem(byte_size(s), 8)
    if pad == 8, do: s, else: s <> String.duplicate(<<0>>, pad)
  end

  # The 64 bit little endian representation of the number `n`
  defp serialize_int(n) do
    <<n::little-integer-size(64)>>
  end

  # Pads and serializes a string `s` as a 64 bit little endian length followed by the string
  defp string(s) do
    size = byte_size(s)
    s = pad(s)
    serialize_int(size) <> s
  end

  @doc """
  Serializes and archives a given path `fso`, returning a binary
  """
  def serialize!(fso, opts \\ []) when is_binary(fso) do
    type =
      if :bytestream in opts do
        :bytestream
      else
        File.lstat!(fso).type
      end

    data = ["nix-archive-1", serialize_p(fso, type)] |> List.flatten()
    # join bytes together
    Enum.map_join(data, &string/1)
  end

  # Deserialize

  @doc """
  Get the default permissions used when deserializing
  """
  def default_perms, do: @default_perms

  @doc """
  Get the executable permissions used when deserializing
  """
  def executable_perms, do: @executable_perms

  defp size(n), do: :binary.decode_unsigned(n, :little)

  defp block_size(n) do
    (((n - 1) / 8) |> floor()) + 1
  end

  defp read_block(size, stream) do
    block_size = block_size(size)

    block =
      Enum.take(stream, block_size)
      |> Enum.join("")
      |> :binary.bin_to_list()
      # Remove padding
      |> Enum.slice(0..(size - 1))
      |> :binary.list_to_bin()

    remainder = Enum.drop(stream, block_size)
    {block, remainder}
  end

  defp parse_block({_block, stream}) do
    parse_block(stream)
  end

  defp parse_block(stream) do
    {size, stream} = read_block(8, stream)

    if size == "" do
      {"", stream}
    else
      read_block(size(size), stream)
    end
  end

  defp write(stream, base_path, :regular) do
    {executable_or_contents, stream} = parse_block(stream)

    {executable, {contents, stream}} =
      case executable_or_contents do
        "executable" -> {true, parse_block(stream) |> parse_block() |> parse_block()}
        "contents" -> {false, parse_block(stream)}
      end

    File.write!(base_path, contents)
    perms = if executable, do: @executable_perms, else: @default_perms
    File.chmod!(base_path, perms)
    stream
  end

  defp write(stream, base_path, :symlink) do
    {target, stream} = parse_block(stream) |> parse_block()
    # Remove padding, symlinks don't like null terminators apparently
    File.ln_s!(target, base_path)
    stream
  end

  defp write(stream, base_path, :directory) do
    File.mkdir_p!(base_path)
    write_entries(stream, base_path)
  end

  defp write_entry(stream, base_path) do
    # "entry" |> "(" |> "name" |> name
    {name, stream} = parse_block(stream) |> parse_block() |> parse_block() |> parse_block()

    # "node" |> node
    {_, stream} = parse_block(stream)
    # stream = parse_stream(stream, Path.join(base_path, name))
    stream = write_object(stream, Path.join(base_path, name))

    # drop last parens
    {_, stream} = parse_block(stream)
    stream
  end

  defp write_entries(stream, base_path) do
    {look_ahead, _} = parse_block(stream)

    if look_ahead == "entry" do
      write_entry(stream, base_path) |> write_entries(base_path)
    else
      stream
    end
  end

  defp write_object(stream, base_path) do
    # "(" |> "type" |> type
    {_, stream} = parse_block(stream) |> parse_block()
    {type, stream} = parse_block(stream)

    case type do
      "regular" -> write(stream, base_path, :regular)
      "symlink" -> write(stream, base_path, :symlink)
      "directory" -> write(stream, base_path, :directory)
    end
    # Drop last parens
    |> parse_block()
  end

  defp parse_stream(stream, base_path) do
    case parse_block(stream) do
      {_, stream} = {"nix-archive-1", _} ->
        write_object(stream, base_path) |> parse_stream(base_path)

      {"", _} ->
        :ok

      {_, _} ->
        raise "Contents do not look like NAR"
    end
  end

  @doc """
  Given binary representing Nix Archive `nar` unpack it to `output_path`
  """
  def deserialize!(nar, output_path) when is_binary(nar) and is_binary(output_path) do
    nar
    |> :binary.bin_to_list()
    |> Enum.chunk_every(8)
    |> Enum.map(&:binary.list_to_bin/1)
    |> parse_stream(output_path)
  end
end