Skip to content

Commit f052c4f

Browse files
committed
Support modules with encrypted debug info
Addresses elixir-lang#1928. - Add configuration options to support decrypting debug info - Update documentation with new options, examples, and advice - Update test helper so that debug info options are forwarded to the compiler - Add basic tests - Update changelog
1 parent d571628 commit f052c4f

File tree

8 files changed

+231
-7
lines changed

8 files changed

+231
-7
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Improve warning when referencing type from a private module
77
* Rename "Search HexDocs package" modal to "Go to package docs". Support built-in Erlang/OTP
88
apps.
9+
* Support modules with encrypted debug info
910

1011
* Bug fixes
1112
* Switch anchor `title` to `aria-label`

lib/ex_doc/config.ex

+32
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ defmodule ExDoc.Config do
2121
before_closing_head_tag: &__MODULE__.before_closing_head_tag/1,
2222
canonical: nil,
2323
cover: nil,
24+
debug_info_fn: nil,
2425
deps: [],
2526
extra_section: nil,
2627
extras: [],
@@ -50,6 +51,10 @@ defmodule ExDoc.Config do
5051
title: nil,
5152
version: nil
5253

54+
@typep debug_info_fn_arg :: :init | :clear | {:debug_info, atom(), module(), :file.filename()}
55+
@typep debug_info_fn :: (debug_info_fn_arg ->
56+
:ok | {:ok, (debug_info_fn_arg -> term())} | {:error, term()})
57+
5358
@type t :: %__MODULE__{
5459
annotations_for_docs: (map() -> list()),
5560
api_reference: boolean(),
@@ -61,6 +66,7 @@ defmodule ExDoc.Config do
6166
before_closing_head_tag: (atom() -> String.t()) | mfa() | map(),
6267
canonical: nil | String.t(),
6368
cover: nil | Path.t(),
69+
debug_info_fn: nil | debug_info_fn(),
6470
deps: [{ebin_path :: String.t(), doc_url :: String.t()}],
6571
extra_section: nil | String.t(),
6672
extras: list(),
@@ -120,7 +126,23 @@ defmodule ExDoc.Config do
120126
guess_url(options[:source_url], options[:source_ref] || @default_source_ref)
121127
end)
122128

129+
{debug_info_key, options} = Keyword.pop(options, :debug_info_key)
130+
131+
{debug_info_fn, options} =
132+
case Keyword.pop(options, :debug_info_fn) do
133+
{nil, options} -> Keyword.pop(options, :debug_info_fun)
134+
{debug_info_fn, options} -> {debug_info_fn, options}
135+
end
136+
137+
debug_info_fn =
138+
cond do
139+
debug_info_fn != nil -> debug_info_fn
140+
debug_info_key != nil -> default_debug_info_fn(debug_info_key)
141+
true -> nil
142+
end
143+
123144
preconfig = %__MODULE__{
145+
debug_info_fn: debug_info_fn,
124146
filter_modules: normalize_filter_modules(filter_modules),
125147
groups_for_modules: normalize_groups_for_modules(groups_for_modules),
126148
homepage_url: options[:homepage_url],
@@ -224,4 +246,14 @@ defmodule ExDoc.Config do
224246
defp append_slash(url) do
225247
if :binary.last(url) == ?/, do: url, else: url <> "/"
226248
end
249+
250+
defp default_debug_info_fn(key) do
251+
key = to_charlist(key)
252+
253+
fn
254+
:init -> :ok
255+
:clear -> :ok
256+
{:debug_info, _mode, _module, _filename} -> key
257+
end
258+
end
227259
end

lib/ex_doc/retriever.ex

+19-2
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ defmodule ExDoc.Retriever do
8080
end
8181

8282
defp get_module(module, config) do
83-
with {:docs_v1, _, language, _, _, _metadata, _} = docs_chunk <- docs_chunk(module),
83+
with {:docs_v1, _, language, _, _, _metadata, _} = docs_chunk <- docs_chunk(module, config),
8484
{:ok, language} <- ExDoc.Language.get(language, module),
8585
%{} = module_data <- language.module_data(module, docs_chunk, config) do
8686
{:ok, generate_node(module, module_data, config)}
@@ -90,7 +90,11 @@ defmodule ExDoc.Retriever do
9090
end
9191
end
9292

93-
defp docs_chunk(module) do
93+
defp docs_chunk(module, config) do
94+
if debug_info_fn = config.debug_info_fn do
95+
set_crypto_key_fn(debug_info_fn)
96+
end
97+
9498
result = Code.fetch_docs(module)
9599
Refs.insert_from_chunk(module, result)
96100

@@ -496,4 +500,17 @@ defmodule ExDoc.Retriever do
496500
defp source_link(source, line) do
497501
Utils.source_url_pattern(source.url, source.path |> Path.relative_to(File.cwd!()), line)
498502
end
503+
504+
@doc false
505+
def set_crypto_key_fn(crypto_key_fn) do
506+
:beam_lib.clear_crypto_key_fun()
507+
508+
case :beam_lib.crypto_key_fun(crypto_key_fn) do
509+
{:error, reason} ->
510+
raise Error, "failed to set crypto_key_fun: #{inspect(reason)}"
511+
512+
other ->
513+
other
514+
end
515+
end
499516
end

lib/mix/tasks/docs.ex

+68
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ defmodule Mix.Tasks.Docs do
100100
the "assets" directory in the output path under the name "cover" and the
101101
appropriate extension. This option has no effect when using the "html" formatter.
102102
103+
* `:debug_info_key` - The key to be used to decrypt debug info that was encrypted during
104+
compilation. This option will be ignored if `:debug_info_fn` or `:debug_info_fun` is provided.
105+
See [Encrypted debug info](`m:Mix.Tasks.Docs#module-encrypted-debug-info`).
106+
107+
* `:debug_info_fn` / `:debug_info_fun` - A function that will be provided to
108+
`:beam_lib.crypto_key_fun/1` to decrypt debug info that was encrypted during compilation. If
109+
both `:debug_info_fn` and `:debug_info_fun` are provided, `:debug_info_fun` will be ignored.
110+
If this option is provided, `:debug_info_key` will be ignored. See
111+
[Encrypted debug info](`m:Mix.Tasks.Docs#module-encrypted-debug-info`).
112+
103113
* `:deps` - A keyword list application names and their documentation URL.
104114
ExDoc will by default include all dependencies and assume they are hosted on
105115
HexDocs. This can be overridden by your own values. Example: `[plug: "https://myserver/plug/"]`
@@ -200,6 +210,64 @@ defmodule Mix.Tasks.Docs do
200210
where path is either an relative path from the cwd, or an absolute path. The function
201211
must return the full URI as it should be placed in the documentation.
202212
213+
## Encrypted debug info
214+
215+
If a module is compiled with [encrypted debug info](`:compile.file/2`), ExDoc will not be able to
216+
extract its documentation without first setting a decryption function or utilizing
217+
`.erlang.crypt` as prescribed by `m::beam_lib#module-encrypted-debug-information`. Two
218+
convenience options (see below) are provided to avoid having to call `:beam_lib.crypto_key_fun/1`
219+
out-of-band and/or to avoid using `.erlang.crypt`.
220+
221+
If you prefer to set the key out-of-band, follow the instructions provided in the
222+
`m::beam_lib#module-encrypted-debug-information` module documentation.
223+
224+
> ### Key exposure {: .warning}
225+
>
226+
> Avoid adding keys directly to your `mix.exs` file. Instead, use an environment variable, an
227+
> external documentation config file, or a
228+
> [closure](https://erlef.github.io/security-wg/secure_coding_and_deployment_hardening/sensitive_data#wrapping).
229+
230+
### `:debug_info_key`
231+
232+
This option can be provided if you only have one key for all encrypted modules. A `t:charlist/0`,
233+
`t:String.t/0`, or tuple of `{:des3_cbc, charlist() | String.t()}` can be used.
234+
235+
### `:debug_info_fn`/`:debug_info_fun`
236+
237+
This option can be provided if you have multiple keys, want more control over key retrieval, or
238+
would like to wrap your key(s) in a closure. `:debug_info_key` will be ignored if this option is
239+
also present. `:debug_info_fun` will be ignored if `:debug_info_fn` is already present.
240+
241+
A basic function that provides the decryption key `SECRET`:
242+
243+
<!-- tabs-open -->
244+
245+
### Elixir
246+
247+
⚠️ The key returned must be a `t:charlist/0`!
248+
249+
```elixir
250+
fn
251+
:init -> :ok,
252+
{:debug_info, _mode, _module, _filename} -> ~c"SECRET"
253+
:clear -> :ok
254+
end
255+
```
256+
257+
### Erlang
258+
259+
```erlang
260+
fun
261+
(init) -> ok;
262+
({debug_info, _Mode, _Module, _Filename}) -> "SECRET";
263+
(clear) -> ok
264+
end.
265+
```
266+
267+
<!-- tabs-close -->
268+
269+
See `:beam_lib.crypto_key_fun/1` for more information.
270+
203271
## Groups
204272
205273
ExDoc content can be organized in groups. This is done via the `:groups_for_extras`

test/ex_doc/config_test.exs

+48
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,52 @@ defmodule ExDoc.ConfigTest do
7474
assert config.skip_code_autolink_to.("ConfigTest.Hidden.bar/1")
7575
refute config.skip_code_autolink_to.("ConfigTest.NotHidden")
7676
end
77+
78+
test "produces a function when a debug_info_key is provided" do
79+
config = ExDoc.Config.build(@project, @version, debug_info_key: "Hunter2")
80+
81+
assert config.debug_info_fn.(:init) == :ok
82+
assert config.debug_info_fn.(:clear) == :ok
83+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"Hunter2"
84+
end
85+
86+
test "ignores debug_info_key when debug_info_fn or debug_info_fun is provided" do
87+
config =
88+
ExDoc.Config.build(@project, @version,
89+
debug_info_key: "Hunter2",
90+
debug_info_fn: debug_info_fn(~c"foxtrot")
91+
)
92+
93+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"foxtrot"
94+
95+
config =
96+
ExDoc.Config.build(@project, @version,
97+
debug_info_key: "Hunter2",
98+
debug_info_fun: debug_info_fn(~c"tango")
99+
)
100+
101+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"tango"
102+
end
103+
104+
test "handles either debug_info_fn or debug_info_fun, but debug_info_fn takes precedence" do
105+
config =
106+
ExDoc.Config.build(@project, @version,
107+
debug_info_fun: debug_info_fn(~c"fun"),
108+
debug_info_fn: debug_info_fn(~c"fn")
109+
)
110+
111+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"fn"
112+
113+
config = ExDoc.Config.build(@project, @version, debug_info_fun: debug_info_fn(~c"fun"))
114+
115+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"fun"
116+
end
117+
118+
defp debug_info_fn(key) do
119+
fn
120+
:init -> :ok
121+
:clear -> :ok
122+
{:debug_info, _mode, _module, _filename} -> key
123+
end
124+
end
77125
end

test/ex_doc/retriever/erlang_test.exs

+42
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,48 @@ defmodule ExDoc.Retriever.ErlangTest do
111111
~r'Equivalent to <a href="`function2/1`"><code[^>]+>function2\(\[\{test, args\}\]\).*\.'
112112
end
113113

114+
test "with encrypted debug_info", c do
115+
erlc(
116+
c,
117+
:debug_info_mod,
118+
~S"""
119+
-module(debug_info_mod).
120+
-moduledoc("mod docs.").
121+
-export([function1/0]).
122+
-export_type([foo/0]).
123+
124+
-doc("foo/0 docs.").
125+
-type foo() :: atom().
126+
127+
-doc("function1/0 docs.").
128+
-spec function1() -> atom().
129+
function1() -> ok.
130+
""",
131+
debug_info_key: ~c"SECRET"
132+
)
133+
134+
# the emitted warning is expected
135+
assert {[], []} == Retriever.docs_from_modules([:debug_info_mod], %ExDoc.Config{})
136+
137+
config = ExDoc.Config.build("debug_info_mod", 1, debug_info_key: ~c"SECRET")
138+
139+
{[mod], []} = Retriever.docs_from_modules([:debug_info_mod], config)
140+
141+
assert %ExDoc.ModuleNode{
142+
moduledoc_file: moduledoc_file,
143+
docs: [function1],
144+
id: "debug_info_mod",
145+
module: :debug_info_mod,
146+
title: "debug_info_mod",
147+
typespecs: [foo]
148+
} = mod
149+
150+
assert DocAST.to_string(mod.doc) =~ "mod docs."
151+
assert DocAST.to_string(function1.doc) =~ "function1/0 docs."
152+
assert DocAST.to_string(foo.doc) =~ "foo/0 docs."
153+
assert moduledoc_file =~ "debug_info_mod.erl"
154+
end
155+
114156
test "module included files", c do
115157
erlc(c, :mod, ~S"""
116158
-file("module.hrl", 1).

test/ex_doc/retriever_test.exs

+10
Original file line numberDiff line numberDiff line change
@@ -307,4 +307,14 @@ defmodule ExDoc.RetrieverTest do
307307
%{docs: [%{signature: signature}]} = module_node
308308
assert signature == "callback_name(arg1, integer, %Date{}, term, t)"
309309
end
310+
311+
test "set_crypto_key_fn/1 raises if it receives an error" do
312+
assert_raise(
313+
Retriever.Error,
314+
"failed to set crypto_key_fun: :badfun",
315+
fn ->
316+
Retriever.set_crypto_key_fn(fn _ -> {:error, :badfun} end)
317+
end
318+
)
319+
end
310320
end

test/test_helper.exs

+11-5
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,20 @@ defmodule TestHelper do
5858

5959
beam_docs = docstrings(docs, context)
6060

61+
# not to be confused with the regular :debug_info opt
62+
debug_info_opts =
63+
Enum.filter(opts, fn
64+
{:debug_info, _debug_info} -> true
65+
{:debug_info_key, _debug_info_key} -> true
66+
:encrypt_debug_info -> true
67+
_ -> false
68+
end)
69+
6170
{:ok, module} =
6271
:compile.file(
6372
String.to_charlist(src_path),
64-
[
65-
:return_errors,
66-
:debug_info,
67-
outdir: String.to_charlist(ebin_dir)
68-
] ++ beam_docs
73+
[:return_errors, :debug_info, outdir: String.to_charlist(ebin_dir)] ++
74+
beam_docs ++ debug_info_opts
6975
)
7076

7177
true = Code.prepend_path(ebin_dir)

0 commit comments

Comments
 (0)