Skip to content

Commit ce6b224

Browse files
authored
Raise on docs tarball unpack failures (#110)
* Raise on docs tarball unpack failures Unpack errors were being swallowed by Logger.error and never reached Sentry, so we had no visibility into malformed publisher tarballs (e.g. lustre 5.7.0 with mode-0 duplicate entries that hit EACCES). Replace Hexdocs.Tar.unpack_to_dir/2 with unpack_to_dir!/2 raising Hexdocs.Tar.UnpackError, and drop the error-swallowing branches in queue.ex and bucket.ex. Broadway's catch logs with crash_reason metadata, which Sentry.LoggerHandler turns into a proper exception event. * Address self-review: correct version reported in delete-flow exception - bucket.ex delete/4: pass new_latest_version (the version actually being unpacked) instead of the version being deleted - tar.ex: replace `unless ok?` with `if not ok?` for clarity - tar_test.exs: pin the invalid-gzip raise to the message prefix
1 parent 5719466 commit ce6b224

4 files changed

Lines changed: 130 additions & 86 deletions

File tree

lib/hexdocs/bucket.ex

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -167,25 +167,22 @@ defmodule Hexdocs.Bucket do
167167

168168
case Hexdocs.Store.get_to_file(:repo_bucket, key, tarball_path) do
169169
:ok ->
170-
case Hexdocs.Tar.unpack_to_dir({:file, tarball_path},
171-
repository: repository,
172-
package: package,
173-
version: version
174-
) do
175-
{:ok, dir, files} ->
176-
upload_files =
177-
list_upload_files(repository, package, new_latest_version, dir, files, :both)
178-
179-
paths = MapSet.new(upload_files, &elem(&1, 0))
180-
update_versions = [version, new_latest_version]
181-
182-
upload_new_files(upload_files)
183-
delete_old_docs(repository, package, update_versions, paths, :both)
184-
purge_hexdocs_cache(repository, package, update_versions, :both)
185-
186-
{:error, reason} ->
187-
Logger.error("Failed unpack #{repository}/#{package} #{version}: #{reason}")
188-
end
170+
{dir, files} =
171+
Hexdocs.Tar.unpack_to_dir!({:file, tarball_path},
172+
repository: repository,
173+
package: package,
174+
version: new_latest_version
175+
)
176+
177+
upload_files =
178+
list_upload_files(repository, package, new_latest_version, dir, files, :both)
179+
180+
paths = MapSet.new(upload_files, &elem(&1, 0))
181+
update_versions = [version, new_latest_version]
182+
183+
upload_new_files(upload_files)
184+
delete_old_docs(repository, package, update_versions, paths, :both)
185+
purge_hexdocs_cache(repository, package, update_versions, :both)
189186

190187
nil ->
191188
Logger.error("Failed to get tarball #{repository}/#{package} #{new_latest_version}")

lib/hexdocs/queue.ex

Lines changed: 42 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,16 @@ defmodule Hexdocs.Queue do
6666

6767
case Hexdocs.Store.get_to_file(:repo_bucket, key, tarball_path) do
6868
:ok ->
69-
case Hexdocs.Tar.unpack_to_dir({:file, tarball_path},
70-
repository: repository,
71-
package: package,
72-
version: version
73-
) do
74-
{:ok, _dir, files} ->
75-
update_index_sitemap(repository, key)
76-
update_package_sitemap(repository, key, package, files)
77-
Logger.info("#{key}: done")
78-
79-
{:error, reason} ->
80-
Logger.error("Failed unpack #{repository}/#{package} #{version}: #{reason}")
81-
end
69+
{_dir, files} =
70+
Hexdocs.Tar.unpack_to_dir!({:file, tarball_path},
71+
repository: repository,
72+
package: package,
73+
version: version
74+
)
75+
76+
update_index_sitemap(repository, key)
77+
update_package_sitemap(repository, key, package, files)
78+
Logger.info("#{key}: done")
8279

8380
nil ->
8481
Logger.error("#{key}: package not found in store")
@@ -163,36 +160,33 @@ defmodule Hexdocs.Queue do
163160
{version, all_versions, retired_versions}
164161
end
165162

166-
case Hexdocs.Tar.unpack_to_dir(input,
167-
repository: repository,
168-
package: package,
169-
version: version
170-
) do
171-
{:ok, dir, files} ->
172-
rewrite_files(dir, files)
173-
174-
Hexdocs.Bucket.upload(
175-
repository,
176-
package,
177-
version,
178-
all_versions,
179-
retired_versions,
180-
dir,
181-
files
182-
)
163+
{dir, files} =
164+
Hexdocs.Tar.unpack_to_dir!(input,
165+
repository: repository,
166+
package: package,
167+
version: version
168+
)
183169

184-
if Hexdocs.Utils.latest_version?(package, version, all_versions) do
185-
update_index_sitemap(repository, key)
186-
update_package_sitemap(repository, key, package, files)
187-
update_package_names_csv(repository)
188-
end
170+
rewrite_files(dir, files)
189171

190-
elapsed = System.os_time(:millisecond) - start
191-
Logger.info("FINISHED UPLOADING DOCS #{key} #{elapsed}ms")
172+
Hexdocs.Bucket.upload(
173+
repository,
174+
package,
175+
version,
176+
all_versions,
177+
retired_versions,
178+
dir,
179+
files
180+
)
192181

193-
{:error, reason} ->
194-
Logger.error("Failed unpack #{repository}/#{package} #{version}: #{reason}")
182+
if Hexdocs.Utils.latest_version?(package, version, all_versions) do
183+
update_index_sitemap(repository, key)
184+
update_package_sitemap(repository, key, package, files)
185+
update_package_names_csv(repository)
195186
end
187+
188+
elapsed = System.os_time(:millisecond) - start
189+
Logger.info("FINISHED UPLOADING DOCS #{key} #{elapsed}ms")
196190
end
197191

198192
defp process_search(key, repository, package, version, input, start) do
@@ -205,15 +199,16 @@ defmodule Hexdocs.Queue do
205199
:error when package in @special_package_names -> version
206200
end
207201

208-
case Hexdocs.Tar.unpack_to_dir(input, package: package, version: version) do
209-
{:ok, dir, files} ->
210-
update_search_index(key, package, version, dir, files)
211-
elapsed = System.os_time(:millisecond) - start
212-
Logger.info("FINISHED INDEXING DOCS #{key} #{elapsed}ms")
202+
{dir, files} =
203+
Hexdocs.Tar.unpack_to_dir!(input,
204+
repository: repository,
205+
package: package,
206+
version: version
207+
)
213208

214-
{:error, reason} ->
215-
Logger.error("Failed unpack #{package} #{version}: #{reason}")
216-
end
209+
update_search_index(key, package, version, dir, files)
210+
elapsed = System.os_time(:millisecond) - start
211+
Logger.info("FINISHED INDEXING DOCS #{key} #{elapsed}ms")
217212
end
218213
end
219214

lib/hexdocs/tar.ex

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
defmodule Hexdocs.Tar do
22
require Logger
33

4+
defmodule UnpackError do
5+
defexception [:repository, :package, :version, :reason]
6+
7+
@impl true
8+
def message(%{repository: repository, package: package, version: version, reason: reason}) do
9+
"Failed to unpack #{repository}/#{package} #{version}: #{reason}"
10+
end
11+
end
12+
413
def create(files) do
514
files = for {path, contents} <- files, do: {String.to_charlist(path), contents}
615
{:ok, tarball} = :hex_tarball.create_docs(files)
716
tarball
817
end
918

10-
def unpack_to_dir({:file, path}, opts \\ []) do
19+
def unpack_to_dir!({:file, path}, opts \\ []) do
1120
repository = Keyword.get(opts, :repository, "UNKNOWN")
1221
package = Keyword.get(opts, :package, "UNKNOWN")
1322
version = Keyword.get(opts, :version, "UNKNOWN")
@@ -26,14 +35,15 @@ defmodule Hexdocs.Tar do
2635
|> Enum.map(&Path.relative_to(&1, output_dir))
2736

2837
files = fix_paths(repository, package, version, files)
29-
30-
case check_version_dirs(files) do
31-
:ok -> {:ok, output_dir, files}
32-
{:error, _} = error -> error
33-
end
38+
check_version_dirs!(repository, package, version, files)
39+
{output_dir, files}
3440

3541
{:error, reason} ->
36-
{:error, inspect(reason)}
42+
raise UnpackError,
43+
repository: repository,
44+
package: package,
45+
version: version,
46+
reason: inspect(reason)
3747
end
3848
end
3949

@@ -55,17 +65,19 @@ defmodule Hexdocs.Tar do
5565
end)
5666
end
5767

58-
defp check_version_dirs(files) do
59-
result =
68+
defp check_version_dirs!(repository, package, version, files) do
69+
ok? =
6070
Enum.all?(files, fn path ->
6171
first = Path.split(path) |> hd()
6272
Version.parse(first) == :error
6373
end)
6474

65-
if result do
66-
:ok
67-
else
68-
{:error, "root file or directory name not allowed to match a semver version"}
75+
if not ok? do
76+
raise UnpackError,
77+
repository: repository,
78+
package: package,
79+
version: version,
80+
reason: "root file or directory name not allowed to match a semver version"
6981
end
7082
end
7183

test/hexdocs/tar_test.exs

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ defmodule Hexdocs.TarTest do
22
use ExUnit.Case, async: true
33
alias Hexdocs.Tar
44

5-
test "unpack_to_dir" do
5+
test "unpack_to_dir!" do
66
blob = Tar.create([{"index.html", "contents"}, {"foo.bar", "contents"}])
77
path = Hexdocs.TmpDir.tmp_file("test-tarball")
88
File.write!(path, blob)
99

10-
assert {:ok, dir, files} = Tar.unpack_to_dir({:file, path})
10+
assert {dir, files} = Tar.unpack_to_dir!({:file, path})
1111
assert File.dir?(dir)
1212
assert length(files) == 2
1313
assert "index.html" in files
@@ -19,19 +19,59 @@ defmodule Hexdocs.TarTest do
1919
test "invalid gzip" do
2020
path = Hexdocs.TmpDir.tmp_file("test-tarball")
2121
File.write!(path, "")
22-
assert {:error, _} = Tar.unpack_to_dir({:file, path})
22+
23+
assert_raise Tar.UnpackError, ~r/Failed to unpack hexpm\/foo 1\.0\.0:/, fn ->
24+
Tar.unpack_to_dir!({:file, path}, repository: "hexpm", package: "foo", version: "1.0.0")
25+
end
2326
end
2427

2528
test "do not allow root files/directories with version names" do
26-
reason = "root file or directory name not allowed to match a semver version"
27-
2829
blob = Tar.create([{"1.0.0", "contents"}])
2930
path = Hexdocs.TmpDir.tmp_file("test-tarball")
3031
File.write!(path, blob)
31-
assert Tar.unpack_to_dir({:file, path}) == {:error, reason}
32+
33+
assert_raise Tar.UnpackError,
34+
~r/root file or directory name not allowed to match a semver version/,
35+
fn ->
36+
Tar.unpack_to_dir!({:file, path},
37+
repository: "hexpm",
38+
package: "foo",
39+
version: "1.0.0"
40+
)
41+
end
3242

3343
blob = Tar.create([{"1.0.0/index.html", "contents"}])
3444
File.write!(path, blob)
35-
assert Tar.unpack_to_dir({:file, path}) == {:error, reason}
45+
46+
assert_raise Tar.UnpackError,
47+
~r/root file or directory name not allowed to match a semver version/,
48+
fn ->
49+
Tar.unpack_to_dir!({:file, path},
50+
repository: "hexpm",
51+
package: "foo",
52+
version: "1.0.0"
53+
)
54+
end
55+
end
56+
57+
test "raises on tarball with duplicate mode-0 entries" do
58+
path = Hexdocs.TmpDir.tmp_file("test-tarball")
59+
{:ok, tar} = :hex_erl_tar.open(String.to_charlist(path), [:write, :compressed])
60+
61+
for _ <- 1..3 do
62+
:ok = :hex_erl_tar.add(tar, "contents", ~c"#", [{:mode, 0}])
63+
end
64+
65+
:ok = :hex_erl_tar.close(tar)
66+
67+
assert_raise Tar.UnpackError,
68+
~r/Failed to unpack hexpm\/lustre 5\.7\.0: :eacces/,
69+
fn ->
70+
Tar.unpack_to_dir!({:file, path},
71+
repository: "hexpm",
72+
package: "lustre",
73+
version: "5.7.0"
74+
)
75+
end
3676
end
3777
end

0 commit comments

Comments
 (0)