lib/nerves/runtime/kv_backend/uboot_env_ubi.ex

defmodule Nerves.Runtime.KVBackend.UBootEnvUBI do
  @moduledoc """
  A `Nerves.Runtime.KVBackend` for U-Boot environments stored in UBI volumes.

  ## Why this exists

  Writing to a UBI volume character device (`/dev/ubi0_N`) requires the
  `UBI_IOCVOLUP` ioctl to enter atomic-update mode. Plain `pwrite/2`
  (used by Erlang `:file` and the default
  `Nerves.Runtime.KVBackend.UBootEnv`) returns `EPERM`. The standard
  Nerves backend therefore can't write the env on UBI-backed boards;
  `Nerves.Runtime.validate_firmware/0` and the whole
  `Nerves.Runtime.StartupGuard` chain fall over with `{:error, :eperm}`.

  This backend works around the gap by:

    * **Reading** through the Erlang `UBootEnv` library (which uses
      plain `pread/3` — that works fine on `/dev/ubi*` character
      devices).
    * **Writing** by shelling out to the C `fw_setenv` tool from
      `u-boot-tools`, which issues the `UBI_IOCVOLUP` ioctl
      transparently for `/dev/ubi*` paths.

  ## Requirements on the target system

    * `/etc/fw_env.config` pointing at the UBI volumes that hold the
      env (one or two volumes for redundancy).
    * `fw_setenv` available somewhere on the device. Defaults to
      `/usr/sbin/fw_setenv`; override with the `:fw_setenv` option.

  ## Usage

  Add to your firmware app's deps:

      {:nerves_uboot_env_ubi, "~> 0.1"}

  Wire it up in `config/target.exs`:

      config :nerves_runtime,
        kv_backend: {Nerves.Runtime.KVBackend.UBootEnvUBI, []}

  Or with options:

      config :nerves_runtime,
        kv_backend: {Nerves.Runtime.KVBackend.UBootEnvUBI,
                     [fw_setenv: "/sbin/fw_setenv"]}

  ## Options

    * `:fw_setenv` — path to the `fw_setenv` binary. Defaults to
      `"/usr/sbin/fw_setenv"`.
  """

  @behaviour Nerves.Runtime.KVBackend

  @default_fw_setenv "/usr/sbin/fw_setenv"

  @impl Nerves.Runtime.KVBackend
  def load(_options) do
    UBootEnv.read()
  end

  @impl Nerves.Runtime.KVBackend
  def save(%{} = kv, options) do
    fw_setenv = Keyword.get(options, :fw_setenv, @default_fw_setenv)

    # fw_setenv -s reads commands from a file, one per line.
    # Format: <key> <value>\n  (space separates; rest of line is value)
    script =
      kv
      |> Enum.map_join("\n", fn {k, v} -> "#{k} #{v}" end)
      |> Kernel.<>("\n")

    path =
      Path.join(
        System.tmp_dir!(),
        "nerves_uboot_env_ubi_#{System.unique_integer([:positive])}.env"
      )

    try do
      File.write!(path, script)

      case System.cmd(fw_setenv, ["-s", path], stderr_to_stdout: true) do
        {_out, 0} -> :ok
        {out, code} -> {:error, "#{fw_setenv} -s exited #{code}: #{String.trim(out)}"}
      end
    after
      _ = File.rm(path)
    end
  end
end