From 012f4155412d672f8205cf759b8b4ea494f4a66b Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Tue, 7 Nov 2023 15:31:50 +0100 Subject: [PATCH] harden docs fetching against changing cwd --- lib/elixir_sense/core/normalized/code.ex | 124 +++++++++++++++- lib/elixir_sense/core/normalized/path.ex | 173 +++++++++++++++++++++++ lib/elixir_sense/location.ex | 7 +- 3 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 lib/elixir_sense/core/normalized/path.ex diff --git a/lib/elixir_sense/core/normalized/code.ex b/lib/elixir_sense/core/normalized/code.ex index 69903f25..3a3cb366 100644 --- a/lib/elixir_sense/core/normalized/code.ex +++ b/lib/elixir_sense/core/normalized/code.ex @@ -5,6 +5,7 @@ defmodule ElixirSense.Core.Normalized.Code do alias ElixirSense.Core.Behaviours alias ElixirSense.Core.ErlangHtml + alias ElixirSense.Core.Normalized.Path, as: PathNormalized @type doc_t :: nil | false | String.t() @type fun_doc_entry_t :: @@ -22,7 +23,7 @@ defmodule ElixirSense.Core.Normalized.Code do @spec get_docs(module, :callback_docs | :type_docs) :: nil | [:doc_entry_t] @spec get_docs(module, :moduledoc) :: nil | moduledoc_entry_t def get_docs(module, category) do - case Code.fetch_docs(module) do + case fetch_docs(module) do {:docs_v1, moduledoc_anno, _language, mime_type, moduledoc, metadata, docs} when mime_type in @supported_mime_types -> case category do @@ -186,4 +187,125 @@ defmodule ElixirSense.Core.Normalized.Code do end defp maybe_mark_as_hidden(metadata, _text), do: metadata + + # the functions below are copied from elixir project + # https://github.com/lukaszsamson/elixir/blob/bf3e2fd3ad78235bda059b80994a90d9a4184353/lib/elixir/lib/code.ex + # with applied https://github.com/elixir-lang/elixir/pull/13061 + # and https://github.com/elixir-lang/elixir/pull/13075 + # as well as some small stability fixes + # TODO remove when we require elixir 1.16 + # The original code is licensed as follows: + # + # Copyright 2012 Plataformatec + # Copyright 2021 The Elixir Team + # + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # https://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. + + @spec fetch_docs(module | String.t()) :: + {:docs_v1, annotation, beam_language, format, module_doc :: doc_content, metadata, + docs :: [doc_element]} + | {:error, any} + when annotation: :erl_anno.anno(), + beam_language: :elixir | :erlang | atom(), + doc_content: %{optional(binary) => binary} | :none | :hidden, + doc_element: + {{kind :: atom, function_name :: atom, arity}, annotation, signature, doc_content, + metadata}, + format: binary, + signature: [binary], + metadata: map + def fetch_docs(module_or_path) + + def fetch_docs(module) when is_atom(module) do + case get_beam_and_path(module) do + {bin, beam_path} -> + case fetch_docs_from_beam(bin) do + {:error, :chunk_not_found} -> + app_root = PathNormalized.expand(Path.join(["..", ".."]), beam_path) + path = Path.join([app_root, "doc", "chunks", "#{module}.chunk"]) + fetch_docs_from_chunk(path) + + other -> + other + end + + :error -> + case :code.is_loaded(module) do + {:file, :preloaded} -> + # The ERTS directory is not necessarily included in releases + # unless it is listed as an extra application. + case :code.lib_dir(:erts) do + path when is_list(path) -> + path = Path.join([path, "doc", "chunks", "#{module}.chunk"]) + fetch_docs_from_chunk(path) + + {:error, _} -> + {:error, :chunk_not_found} + end + + _ -> + {:error, :module_not_found} + end + end + end + + def fetch_docs(path) when is_binary(path) do + fetch_docs_from_beam(String.to_charlist(path)) + end + + defp get_beam_and_path(module) do + with {^module, beam, filename} <- :code.get_object_code(module), + info_pairs when is_list(info_pairs) <- :beam_lib.info(beam), + {:ok, ^module} <- Keyword.fetch(info_pairs, :module) do + {beam, filename} + else + _ -> :error + end + end + + @docs_chunk [?D, ?o, ?c, ?s] + + defp fetch_docs_from_beam(bin_or_path) do + case :beam_lib.chunks(bin_or_path, [@docs_chunk]) do + {:ok, {_module, [{@docs_chunk, bin}]}} -> + load_docs_chunk(bin) + + {:error, :beam_lib, {:missing_chunk, _, @docs_chunk}} -> + {:error, :chunk_not_found} + + {:error, :beam_lib, {:file_error, _, :enoent}} -> + {:error, :module_not_found} + + # TODO raise a PR to elixir + {:error, :beam_lib, reason} -> + {:error, reason} + end + end + + defp fetch_docs_from_chunk(path) do + case File.read(path) do + {:ok, bin} -> + load_docs_chunk(bin) + + {:error, _} -> + {:error, :chunk_not_found} + end + end + + defp load_docs_chunk(bin) do + :erlang.binary_to_term(bin) + rescue + _ -> + {:error, {:invalid_chunk, bin}} + end end diff --git a/lib/elixir_sense/core/normalized/path.ex b/lib/elixir_sense/core/normalized/path.ex new file mode 100644 index 00000000..aa54cfe5 --- /dev/null +++ b/lib/elixir_sense/core/normalized/path.ex @@ -0,0 +1,173 @@ +defmodule ElixirSense.Core.Normalized.Path do + # the functions below are copied from elixir project + # https://github.com/lukaszsamson/elixir/blob/bf3e2fd3ad78235bda059b80994a90d9a4184353/lib/elixir/lib/path.ex + # with applied https://github.com/elixir-lang/elixir/pull/13061 + # TODO remove when we require elixir 1.16 + # The original code is licensed as follows: + # + # Copyright 2012 Plataformatec + # Copyright 2021 The Elixir Team + # + # Licensed under the Apache License, Version 2.0 (the "License"); + # you may not use this file except in compliance with the License. + # You may obtain a copy of the License at + # + # https://www.apache.org/licenses/LICENSE-2.0 + # + # Unless required by applicable law or agreed to in writing, software + # distributed under the License is distributed on an "AS IS" BASIS, + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + # See the License for the specific language governing permissions and + # limitations under the License. + + @type t :: IO.chardata() + + @spec absname(t) :: binary + def absname(path) do + absname(path, &File.cwd!/0) + end + + @spec absname(t, t) :: binary + def absname(path, relative_to) do + path = IO.chardata_to_string(path) + + case Path.type(path) do + :relative -> + relative_to = + if is_function(relative_to, 0) do + relative_to.() + else + relative_to + end + + absname_join([relative_to, path]) + + :absolute -> + absname_join([path]) + + :volumerelative -> + relative_to = + if is_function(relative_to, 0) do + relative_to.() + else + relative_to + end + |> IO.chardata_to_string() + + absname_vr(Path.split(path), Path.split(relative_to), relative_to) + end + end + + # Absolute path on current drive + defp absname_vr(["/" | rest], [volume | _], _relative), do: absname_join([volume | rest]) + + # Relative to current directory on current drive + defp absname_vr([<> | rest], [<> | _], relative), + do: absname(absname_join(rest), relative) + + # Relative to current directory on another drive + defp absname_vr([<> | name], _, _relative) do + cwd = + case :file.get_cwd([x, ?:]) do + {:ok, dir} -> IO.chardata_to_string(dir) + {:error, _} -> <> + end + + absname(absname_join(name), cwd) + end + + @slash [?/, ?\\] + + defp absname_join([]), do: "" + defp absname_join(list), do: absname_join(list, major_os_type()) + + defp absname_join([name1, name2 | rest], os_type) do + joined = do_absname_join(IO.chardata_to_string(name1), Path.relative(name2), [], os_type) + absname_join([joined | rest], os_type) + end + + defp absname_join([name], os_type) do + do_absname_join(IO.chardata_to_string(name), <<>>, [], os_type) + end + + defp do_absname_join(<>, relativename, [], :win32) + when uc_letter in ?A..?Z, + do: do_absname_join(rest, relativename, [?:, uc_letter + ?a - ?A], :win32) + + defp do_absname_join(<>, relativename, [], :win32) + when c1 in @slash and c2 in @slash, + do: do_absname_join(rest, relativename, ~c"//", :win32) + + defp do_absname_join(<>, relativename, result, :win32), + do: do_absname_join(<>, relativename, result, :win32) + + defp do_absname_join(<>, relativename, [?., ?/ | result], os_type), + do: do_absname_join(rest, relativename, [?/ | result], os_type) + + defp do_absname_join(<>, relativename, [?/ | result], os_type), + do: do_absname_join(rest, relativename, [?/ | result], os_type) + + defp do_absname_join(<<>>, <<>>, result, os_type), + do: IO.iodata_to_binary(reverse_maybe_remove_dir_sep(result, os_type)) + + defp do_absname_join(<<>>, relativename, [?: | rest], :win32), + do: do_absname_join(relativename, <<>>, [?: | rest], :win32) + + defp do_absname_join(<<>>, relativename, [?/ | result], os_type), + do: do_absname_join(relativename, <<>>, [?/ | result], os_type) + + defp do_absname_join(<<>>, relativename, result, os_type), + do: do_absname_join(relativename, <<>>, [?/ | result], os_type) + + defp do_absname_join(<>, relativename, result, os_type), + do: do_absname_join(rest, relativename, [char | result], os_type) + + defp reverse_maybe_remove_dir_sep([?/, ?:, letter], :win32), do: [letter, ?:, ?/] + defp reverse_maybe_remove_dir_sep([?/], _), do: [?/] + defp reverse_maybe_remove_dir_sep([?/ | name], _), do: :lists.reverse(name) + defp reverse_maybe_remove_dir_sep(name, _), do: :lists.reverse(name) + + @spec expand(t) :: binary + def expand(path) do + expand_dot(absname(expand_home(path), &File.cwd!/0)) + end + + @spec expand(t, t) :: binary + def expand(path, relative_to) do + expand_dot(absname(absname(expand_home(path), expand_home(relative_to)), &File.cwd!/0)) + end + + defp expand_home(type) do + case IO.chardata_to_string(type) do + "~" <> rest -> resolve_home(rest) + rest -> rest + end + end + + defp resolve_home(""), do: System.user_home!() + + defp resolve_home(rest) do + case {rest, major_os_type()} do + {"\\" <> _, :win32} -> System.user_home!() <> rest + {"/" <> _, _} -> System.user_home!() <> rest + _ -> "~" <> rest + end + end + + # expands dots in an absolute path represented as a string + defp expand_dot(path) do + [head | tail] = :binary.split(path, "/", [:global]) + IO.iodata_to_binary(expand_dot(tail, [head <> "/"])) + end + + defp expand_dot([".." | t], [_, _ | acc]), do: expand_dot(t, acc) + defp expand_dot([".." | t], acc), do: expand_dot(t, acc) + defp expand_dot(["." | t], acc), do: expand_dot(t, acc) + defp expand_dot([h | t], acc), do: expand_dot(t, ["/", h | acc]) + defp expand_dot([], ["/", head | acc]), do: :lists.reverse([head | acc]) + defp expand_dot([], acc), do: :lists.reverse(acc) + + defp major_os_type do + :os.type() |> elem(0) + end +end diff --git a/lib/elixir_sense/location.ex b/lib/elixir_sense/location.ex index 0ce5bf7a..12c1676f 100644 --- a/lib/elixir_sense/location.ex +++ b/lib/elixir_sense/location.ex @@ -7,6 +7,7 @@ defmodule ElixirSense.Location do alias ElixirSense.Core.Parser alias ElixirSense.Core.Source alias ElixirSense.Core.State.ModFunInfo + alias ElixirSense.Core.Normalized.Code, as: CodeNormalized require ElixirSense.Core.Introspection, as: Introspection alias ElixirSense.Location @@ -192,7 +193,7 @@ defmodule ElixirSense.Location do end defp get_function_position_using_docs(module, nil, _) do - case Code.fetch_docs(module) do + case CodeNormalized.fetch_docs(module) do {:error, _} -> nil @@ -220,7 +221,7 @@ defmodule ElixirSense.Location do end defp get_function_position_using_docs(module, function, arity) do - case Code.fetch_docs(module) do + case CodeNormalized.fetch_docs(module) do {:error, _} -> nil @@ -257,7 +258,7 @@ defmodule ElixirSense.Location do end def get_type_position_using_docs(module, type_name, arity) do - case Code.fetch_docs(module) do + case CodeNormalized.fetch_docs(module) do {:error, _} -> nil