Skip to content

Commit 8948c6a

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 8948c6a

File tree

8 files changed

+259
-7
lines changed

8 files changed

+259
-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

+36
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,18 @@ 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 =
252+
case key do
253+
{_mode, key} -> to_charlist(key)
254+
key -> to_charlist(key)
255+
end
256+
257+
fn
258+
:init -> :ok
259+
:clear -> :ok
260+
{:debug_info, _mode, _module, _filename} -> key
261+
end
262+
end
227263
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

+72
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,68 @@ 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+
While a module can be encrypted using a tuple key such as `{:des3_cbc, ~c"secret"}`, the function
242+
that provides the key must return a regular charlist. In other words, the function should return
243+
`~c"secret"`, not `{:des3_cbc, ~c"secret"}`.
244+
245+
A basic function that provides the decryption key `SECRET`:
246+
247+
<!-- tabs-open -->
248+
249+
### Elixir
250+
251+
⚠️ The key returned must be a `t:charlist/0`!
252+
253+
```elixir
254+
fn
255+
:init -> :ok,
256+
{:debug_info, _mode, _module, _filename} -> ~c"SECRET"
257+
:clear -> :ok
258+
end
259+
```
260+
261+
### Erlang
262+
263+
```erlang
264+
fun
265+
(init) -> ok;
266+
({debug_info, _Mode, _Module, _Filename}) -> "SECRET";
267+
(clear) -> ok
268+
end.
269+
```
270+
271+
<!-- tabs-close -->
272+
273+
See `:beam_lib.crypto_key_fun/1` for more information.
274+
203275
## Groups
204276
205277
ExDoc content can be organized in groups. This is done via the `:groups_for_extras`

test/ex_doc/config_test.exs

+54
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,58 @@ 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+
85+
config = ExDoc.Config.build(@project, @version, debug_info_key: {:des3_cbc, "Hunter3"})
86+
87+
assert config.debug_info_fn.(:init) == :ok
88+
assert config.debug_info_fn.(:clear) == :ok
89+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"Hunter3"
90+
end
91+
92+
test "ignores debug_info_key when debug_info_fn or debug_info_fun is provided" do
93+
config =
94+
ExDoc.Config.build(@project, @version,
95+
debug_info_key: "Hunter2",
96+
debug_info_fn: debug_info_fn(~c"foxtrot")
97+
)
98+
99+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"foxtrot"
100+
101+
config =
102+
ExDoc.Config.build(@project, @version,
103+
debug_info_key: "Hunter2",
104+
debug_info_fun: debug_info_fn(~c"tango")
105+
)
106+
107+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"tango"
108+
end
109+
110+
test "handles either debug_info_fn or debug_info_fun, but debug_info_fn takes precedence" do
111+
config =
112+
ExDoc.Config.build(@project, @version,
113+
debug_info_fun: debug_info_fn(~c"fun"),
114+
debug_info_fn: debug_info_fn(~c"fn")
115+
)
116+
117+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"fn"
118+
119+
config = ExDoc.Config.build(@project, @version, debug_info_fun: debug_info_fn(~c"fun"))
120+
121+
assert config.debug_info_fn.({:debug_info, nil, nil, nil}) == ~c"fun"
122+
end
123+
124+
defp debug_info_fn(key) do
125+
fn
126+
:init -> :ok
127+
:clear -> :ok
128+
{:debug_info, _mode, _module, _filename} -> key
129+
end
130+
end
77131
end

test/ex_doc/retriever/erlang_test.exs

+56
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,62 @@ 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+
config = ExDoc.Config.build("debug_info_mod", 1, debug_info_key: ~c"SECRET")
135+
136+
{[mod], []} = Retriever.docs_from_modules([:debug_info_mod], config)
137+
138+
assert %ExDoc.ModuleNode{
139+
moduledoc_file: moduledoc_file,
140+
docs: [function1],
141+
id: "debug_info_mod",
142+
module: :debug_info_mod,
143+
title: "debug_info_mod",
144+
typespecs: [foo]
145+
} = mod
146+
147+
assert DocAST.to_string(mod.doc) =~ "mod docs."
148+
assert DocAST.to_string(function1.doc) =~ "function1/0 docs."
149+
assert DocAST.to_string(foo.doc) =~ "foo/0 docs."
150+
assert moduledoc_file =~ "debug_info_mod.erl"
151+
end
152+
153+
test "encrypted with tuple key", c do
154+
erlc(
155+
c,
156+
:debug_info_mod2,
157+
~S"""
158+
-module(debug_info_mod2).
159+
-moduledoc("mod docs.").
160+
""",
161+
debug_info_key: {:des3_cbc, ~c"SECRET"}
162+
)
163+
164+
config = ExDoc.Config.build("debug_info_mod2", 1, debug_info_key: {:des3_cbc, "SECRET"})
165+
166+
assert {[%ExDoc.ModuleNode{module: :debug_info_mod2}], []} =
167+
Retriever.docs_from_modules([:debug_info_mod2], config)
168+
end
169+
114170
test "module included files", c do
115171
erlc(c, :mod, ~S"""
116172
-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)