lib/nerves/utils/wsl.ex

defmodule Nerves.Utils.WSL do
  @moduledoc """
  This module contains utility functions to assist in detecting a Windows
  Subsystem for Linux environment as well as functions to convert paths between
  the Windows host and Linux.
  """

  @doc """
  Returns a two item tuple where the first item is a command and the second is
  the argument list to run a powershell command as administrator in Windows
  """
  def admin_powershell_command(command, args) do
    {
      "powershell.exe",
      [
        "-Command",
        "Start-Process #{command} -Verb runAs -Wait -ArgumentList \"#{args}\""
      ]
    }
  end

  @doc """
  Returns true if inside a WSL shell environment
  """
  @spec running_on_wsl?() :: boolean
  def running_on_wsl?() do
    # Docker Desktop for Windows uses WSL 2 kernel as the back-end
    # so also check whether the env is Docker
    Regex.match?(~r/[Mm]icrosoft/, osrelease()) and not File.exists?("/.dockerenv")
  end

  defp osrelease() do
    case File.read("/proc/sys/kernel/osrelease") do
      {:ok, text} -> text
      _ -> "unknown"
    end
  end

  @doc """
  Gets a list of fwup devices on a Windows host. This function can be run from
  within WSL, as it runs a powershell command to get the list and writes it to a
  temporary file that WSL can access.
  """
  def get_fwup_devices() do
    {win_path, _} = make_file_accessible("fwup_devs.txt", running_on_wsl?(), has_wslpath?())

    {_, wsl_path} = get_wsl_paths(win_path, has_wslpath?())

    powershell_args = "fwup.exe -D | set-content -encoding UTF8 #{win_path}"

    with {command, args} <- admin_powershell_command("powershell.exe", powershell_args),
         {"", 0} <- Nerves.Port.cmd(command, args),
         {:ok, devs} <- File.read(wsl_path) do
      devs =
        Regex.replace(~r/[\x{200B}\x{200C}\x{200D}\x{FEFF}]/u, devs, "")
        |> String.replace("\r", "")

      File.rm(wsl_path)
      {devs, 0}
    else
      {:error, :enoent} ->
        # fwup didn't find any devices and no tmp file was generated
        # behave as fwup does normally and return an empty string result
        {"", 0}

      error ->
        error
    end
  end

  @doc """
  Returns true if the WSL utility `wslpath` is available
  """
  @spec has_wslpath?() :: boolean
  def has_wslpath?() do
    System.find_executable("wslpath") != nil
  end

  @doc """
  Returns true if the path is accessible in Windows
  """
  @spec path_accessible_in_windows?(String.t(), boolean) :: boolean
  def path_accessible_in_windows?(file, _use_wslpath = true) do
    {_path, exitcode} = Nerves.Port.cmd("wslpath", ["-w", "-a", file], stderr_to_stdout: true)
    exitcode == 0
  end

  def path_accessible_in_windows?(file, _use_wslpath) do
    Regex.match?(~r/(\/mnt\/\w{1})\//, file)
  end

  @doc """
  Returns a path to the base file name a temporary location in Windows
  """
  @spec get_temp_file_location(String.t()) :: String.t()
  def get_temp_file_location(file) do
    {win_path, 0} = Nerves.Port.cmd("cmd.exe", ["/c", "echo %TEMP%"])
    "#{String.trim(win_path)}\\#{Path.basename(file)}"
  end

  @doc """
  Returns true when the path matches various kinds of Windows-specific paths, like:

  ```
  C:\\
  C:\\projects
  \\\\myserver\\sharename\\
  \\\\wsl$\\Ubuntu-18.04\\home\\username\\my_project\\
  ```
  """
  @spec valid_windows_path?(String.t()) :: boolean
  def valid_windows_path?(path) do
    Regex.match?(~r/^(\w:|\\\\[\w.$-]+)\\/, path)
  end

  @doc """
  Returns true if the path is not a Windows path
  """
  @spec valid_wsl_path?(String.t()) :: boolean
  def valid_wsl_path?(path) do
    valid_windows_path?(path) === false
  end

  @doc """
  Returns a two item tuple containing the Windows host path for a file and its WSL counterpart.

  If the path is not available in either Windows or WSL, nil will replace the item

  ## Examples

      iex> Nerves.Utils.WSL.get_wsl_paths("mix.exs", Nerves.Utils.WSL.has_wslpath?())
      {"C:\\Users\\username\\src\\nerves\\mix.exs",
      "/mnt/c/Users/username/src/nerves/mix.exs"}

  """
  @spec get_wsl_paths(String.t(), boolean) :: {String.t() | nil, String.t() | nil}
  def get_wsl_paths(file, _use_wslpath = true) do
    # Use wslpath, available from Windows 10 1803
    # https://superuser.com/questions/1113385/convert-windows-path-for-windows-ubuntu-bash
    # -a    force result to absolute path format
    # -u    translate from a Windows path to a WSL path (default)
    # -w    translate from a WSL path to a Windows path
    # -m    translate from a WSL path to a Windows path, with ‘/’ instead of ‘\\’

    win_path =
      if valid_windows_path?(file) do
        file
      else
        execute_wslpath(file, ["-w", "-a"])
      end

    wsl_path =
      if valid_wsl_path?(file) do
        Path.expand(file)
      else
        execute_wslpath(file, ["-u", "-a"])
      end

    {win_path, wsl_path}
  end

  def get_wsl_paths(file, _use_wslpath) do
    # Maintain support for Windows builds before 1803
    fullpath =
      if valid_wsl_path?(file) do
        Path.expand(file)
      else
        file
      end

    # Check if the full path is accessible form Windows
    win_path =
      if Regex.match?(~r/(\/mnt\/\w{1})\//, fullpath) do
        # extract drive letter from path
        %{"drive" => drive_letter} = Regex.named_captures(~r/\/mnt\/(?<drive>\w{1})\//, fullpath)
        # replace /mnt/<drive_letter>/ with windows version C:/
        win_path =
          Regex.replace(~r/(\/mnt\/\w{1})\//, fullpath, "#{String.upcase(drive_letter)}:/")

        # replace forward slashes with backslashes
        Regex.replace(~r/\//, win_path, "\\\\")
      else
        # If path is already a windows path, return it
        if valid_windows_path?(fullpath) do
          fullpath
        else
          nil
        end
      end

    # Check if full path is a windows path
    wsl_path =
      if valid_windows_path?(fullpath) do
        # extract drive letter
        %{"drive" => drive_letter} = Regex.named_captures(~r/(?<drive>^.{1}):/, fullpath)
        # replace <drive_letter>: with /mnt/<drive_letter>
        wsl_path = Regex.replace(~r/^.{1}:/, fullpath, "/mnt/#{String.downcase(drive_letter)}")
        # replace \\ or \ with forward slashes
        Regex.replace(~r/\\\\|\\/, wsl_path, "/")
      else
        # if we are already a wsl path just return it
        fullpath
      end

    {win_path, wsl_path}
  end

  @doc """
  Executes wslpath with the file and arguments.

  When a valid WSL path is passed through to `wslpath` asking for a
  valid path an "Invalid argument" error is returned. This function
  catches this error and returns the valid path.
  """
  @spec execute_wslpath(String.t(), list) :: String.t() | nil
  def execute_wslpath(file, arguments) do
    with {path, 0} <- Nerves.Port.cmd("wslpath", arguments ++ [file], stderr_to_stdout: true) do
      String.trim(path)
    else
      {error, _} ->
        if String.contains?(error, "Invalid argument") do
          Path.expand(file)
        else
          nil
        end
    end
  end

  @doc """
  Returns an item tuple with the Windows accessible path and whether the path is a temporary location or original location
  """
  @spec make_file_accessible(String.t(), boolean, boolean) ::
          {String.t(), :original_location} | {String.t(), :temporary_location}
  def make_file_accessible(file, _is_wsl = true, has_wslpath) do
    if path_accessible_in_windows?(file, has_wslpath) do
      {win_path, _wsl_path} = get_wsl_paths(file, has_wslpath)
      {win_path, :original_location}
    else
      # Create a temporary .fw file that fwup.exe is able to access
      temp_file_location = get_temp_file_location(file)
      {win_path, wsl_path} = get_wsl_paths(temp_file_location, has_wslpath)
      File.copy(file, wsl_path)
      {win_path, :temporary_location}
    end
  end

  def make_file_accessible(file, _is_wsl, _has_wslpath) do
    {file, :original_location}
  end

  @doc """
  If the file was created in a temporary location, get the WSL path and delete it. Otherwise return `:ok`
  """
  @spec cleanup_file(String.t(), :temporary_location | :original_location) :: :ok | {:error, atom}
  def cleanup_file(file, :temporary_location) do
    {_win_path, wsl_path} = get_wsl_paths(file, has_wslpath?())
    File.rm(wsl_path)
  end

  def cleanup_file(_, _), do: :ok
end