Make the Debian repository browsable

279c2d1 · Gabriel Morell · 2026-09-11 19:51

10 files +510 -4
Message
{commit_body(@commit)}

Files changed

modified README.md
+10 −1
@@ -97,7 +97,7 @@ reinvent them.
97 97 - **PyPI** ([PEP 503](https://peps.python.org/pep-0503/) + [PEP 691](https://peps.python.org/pep-0691/)) — `pip install --index-url …/simple/`, `twine upload`. Both serialisations off one data structure, negotiated on `Accept` with real q-values; PEP 592 yank state; name normalisation applied before every lookup
98 98 - **Hex** ([registry v2](https://github.com/hexpm/specifications/blob/main/registry-v2.md)) — `mix hex.repo add` + `{:dep, "~> 1.0", repo: "…"}`. Protobuf encoded per request out of Postgres rather than rebuilt by `mix hex.registry build`, so publishing is an insert instead of O(everything)
99 99 - **Cargo** ([sparse index](https://doc.rust-lang.org/cargo/reference/registry-index.html)) — `cargo publish`, `add`, `yank`. Prefix-sharded index with real `ETag`/`304` revalidation, `features2`/`v: 2` splitting, and the `explicit_name_in_toml` → `package` rename translation
100 − - **apt** ([DebianRepository/Format](https://wiki.debian.org/DebianRepository/Format)) — signed `InRelease`, `Packages.xz`, pool. **Namespace-wide repositories at `_all`**: one `deb` line and one keyring file for everything an org (or a user) publishes, rather than one of each per repository
100 + - **apt** ([DebianRepository/Format](https://wiki.debian.org/DebianRepository/Format)) — signed `InRelease`, `Packages.xz`, pool. **Namespace-wide repositories at `_all`**: one `deb` line and one keyring file for everything an org (or a user) publishes, rather than one of each per repository. **Browsable**, the way a real mirror is — click down through `dists/` and `pool/` in a browser, in the forge's own theme, with the listing derived from what the repository actually serves so it can neither advertise a 404 nor show a repository you couldn't read
101 101 - **OCI** hosted in [zot](https://zotregistry.dev/) — the forge owns `/v2/token` (`docker login` → Basic-auth handshake → RS256 JWT scoped to `repository:<owner>/<name>:pull,push`); zot owns the manifests and blobs. Deployment manifest in `zot.yml`; keypair from `mix gitgud.registry.keygen`; `(package, version)` mirrored via `/_webhooks/zot`
102 102 - **Publish from CI with no stored secret** — `${{ github.token }}` is accepted by every registry and authorized to write its own repository only, exactly as at the OCI token endpoint
103 103 - **Signing keys are per namespace**, not per repository — one `signed-by=` entry or one `--public-key` covers every repository an org publishes. Served at `/pkg/_keys/:format/:owner`; private halves encrypted at rest under the `GitGud.Secrets` master key
@@ -403,6 +403,15 @@ echo "deb [signed-by=/etc/apt/keyrings/acme.asc] https://<host>/pkg/deb/acme/too
403 403 | sudo tee /etc/apt/sources.list.d/acme-tools.list
404 404 ```
405 405
406 +Both roots are browsable in a browser as well as by apt —
407 +`dists/` and `pool/` list like any mirror's autoindex, styled with
408 +whatever daisyUI theme the viewer has selected. The listing is derived
409 +from the paths the repository actually serves, so it cannot offer a
410 +link that 404s, and it is built from the same permission-filtered
411 +repository set as the index, so it cannot name a repository the viewer
412 +couldn't have fetched directly. apt never requests a directory, so none
413 +of this is on its path.
414 +
406 415 `_all` can never collide with a repository, because a repository name
407 416 must start with an alphanumeric. It aggregates every repository in the
408 417 namespace **the caller may read** — anonymous sees the public ones, a
modified lib/git_gud/packages/formats/deb.ex
+207 −3
@@ -143,7 +143,7 @@ defmodule GitGud.Packages.Formats.Deb do
143 143 end
144 144 end
145 145
146 − defp serve(repos, _label, _scope, %{path: ["pool" | _] = path}) do
146 + defp serve(repos, label, _scope, %{path: ["pool" | _] = path} = ctx) do
147 147 # Matched against the whole pool path, not just the filename: two
148 148 # repositories in one namespace may well both publish
149 149 # `widget_1.0_amd64.deb`, and serving the wrong one would be a
@@ -155,11 +155,13 @@ defmodule GitGud.Packages.Formats.Deb do
155 155 |> Enum.find(&(pool_path(&1) == wanted))
156 156 |> case do
157 157 %{file: %PackageFile{} = file} -> {:blob, file}
158 − nil -> {:error, :not_found}
158 + nil -> listing(repos, label, ctx)
159 159 end
160 160 end
161 161
162 − defp serve(_repos, _label, _scope, _ctx), do: {:error, :not_found}
162 + # Anything that isn't a file this repository serves may still be a
163 + # directory of them.
164 + defp serve(repos, label, _scope, ctx), do: listing(repos, label, ctx)
163 165
164 166 defp sign(scope, signer, document, content_type: content_type) do
165 167 with {:ok, %{private_pem: secret}} <- Packages.signing_key(scope, @format),
@@ -170,6 +172,208 @@ defmodule GitGud.Packages.Formats.Deb do
170 172 end
171 173 end
172 174
175 + # ── browsing ────────────────────────────────────────────────────────
176 +
177 + # A Debian mirror is a tree of files over plain HTTP, and every one of
178 + # them is conventionally browsable — `deb.debian.org/debian/` is an
179 + # autoindex you can click down through. Ours only ever answered exact
180 + # paths, so pointing a browser at a repository got a 404 and there was
181 + # no way to see what was published without already knowing its name.
182 + #
183 + # apt never requests a directory, so none of this is on its path; it
184 + # exists for the person checking whether their upload landed.
185 + #
186 + # The listing is derived from the paths this repository actually
187 + # serves, never from the filesystem or from a separate enumeration —
188 + # so it cannot advertise something that would 404, and it cannot leak
189 + # a repository the caller couldn't already read, because `repos` was
190 + # filtered before it got here.
191 + defp listing(repos, label, %{path: prefix} = ctx) do
192 + case children(served_paths(repos), prefix) do
193 + # An empty listing below the root is a path that doesn't exist,
194 + # not an empty directory — the tree has no empty nodes in it.
195 + [] when prefix != [] -> {:error, :not_found}
196 + entries -> {:ok, "text/html; charset=utf-8", index_page(label, ctx, prefix, entries)}
197 + end
198 + end
199 +
200 + # Every path this repository answers to, with the size and date where
201 + # we know them for free. Index documents are generated per request, so
202 + # sizing them here would mean building all of them to draw a table.
203 + defp served_paths(repos) do
204 + debs = debs(repos)
205 +
206 + dists =
207 + debs
208 + |> Enum.group_by(& &1.dist)
209 + |> Enum.flat_map(fn {dist, in_dist} ->
210 + components = in_dist |> Enum.map(& &1.component) |> Enum.uniq()
211 + architectures = in_dist |> Enum.map(& &1.architecture) |> Enum.uniq()
212 +
213 + ~w(InRelease Release Release.gpg)
214 + |> Enum.map(&"dists/#{dist}/#{&1}")
215 + |> Enum.concat(
216 + for component <- components,
217 + arch <- architectures,
218 + name <- ["Packages", "Packages.xz"] do
219 + "dists/#{dist}/#{component}/binary-#{arch}/#{name}"
220 + end
221 + )
222 + end)
223 +
224 + Enum.map(["key.asc" | dists], &%{path: &1, size: nil, at: nil}) ++
225 + Enum.map(debs, &%{path: pool_path(&1), size: &1.file.size, at: &1.file.inserted_at})
226 + end
227 +
228 + # The immediate children of `prefix`: a path with exactly one segment
229 + # left is a file, more than one means a directory we've now seen.
230 + defp children(paths, prefix) do
231 + depth = length(prefix)
232 +
233 + paths
234 + |> Enum.flat_map(fn entry ->
235 + segments = String.split(entry.path, "/")
236 +
237 + if List.starts_with?(segments, prefix) do
238 + case Enum.drop(segments, depth) do
239 + [name] -> [%{name: name, dir?: false, size: entry.size, at: entry.at}]
240 + [name | _] -> [%{name: name, dir?: true, size: nil, at: nil}]
241 + [] -> []
242 + end
243 + else
244 + []
245 + end
246 + end)
247 + |> Enum.uniq_by(& &1.name)
248 + |> Enum.sort_by(&{not &1.dir?, &1.name})
249 + end
250 +
251 + # A plain document rather than the app layout — this is served from
252 + # the registry URL, which a browser may well reach with a Basic-auth
253 + # prompt and no session at all — but it still wears the forge's
254 + # colours.
255 + #
256 + # It pulls in `app.css` for the daisyUI theme definitions and repeats
257 + # the root layout's theme bootstrap, so `data-theme` is set before
258 + # first paint and the page matches whatever the viewer picked. Styling
259 + # then goes through the theme's own custom properties rather than
260 + # Tailwind utility classes: this module lives under `lib/git_gud`, and
261 + # `app.css` only scans `lib/git_gud_web`, so utility classes written
262 + # here would be purged out of the bundle and silently do nothing.
263 + #
264 + # The bootstrap is read-only — no picker on this page, so it never
265 + # writes `phx:theme` — but it does listen for `storage`, so changing
266 + # the theme in another tab recolours this one.
267 + defp index_page(label, ctx, prefix, entries) do
268 + here = Enum.join([ctx.base_url | prefix], "/")
269 +
270 + rows =
271 + Enum.map(entries, fn entry ->
272 + href = here <> "/" <> URI.encode(entry.name) <> if(entry.dir?, do: "/", else: "")
273 +
274 + [
275 + "<tr><td><a class=\"",
276 + if(entry.dir?, do: "dir", else: "file"),
277 + "\" href=\"",
278 + esc(href),
279 + "\">",
280 + esc(entry.name),
281 + if(entry.dir?, do: "/", else: ""),
282 + "</a></td><td class=\"n\">",
283 + format_size(entry.size),
284 + "</td><td class=\"n\">",
285 + format_date(entry.at),
286 + "</td></tr>\n"
287 + ]
288 + end)
289 +
290 + parent =
291 + if prefix == [] do
292 + []
293 + else
294 + up = Enum.join([ctx.base_url | Enum.drop(prefix, -1)], "/") <> "/"
295 + ["<tr><td><a class=\"dir\" href=\"", esc(up), "\">../</a></td><td></td><td></td></tr>\n"]
296 + end
297 +
298 + title = "/" <> Enum.join(prefix, "/")
299 +
300 + [
301 + """
302 + <!DOCTYPE html>
303 + <html lang="en"><head><meta charset="utf-8">
304 + <meta name="viewport" content="width=device-width, initial-scale=1">
305 + <title>#{esc(label)} #{esc(title)}</title>
306 + <link rel="stylesheet" href="#{esc(stylesheet_path())}">
307 + <script>
308 + (() => {
309 + const SYSTEM_DARK = "afterdark";
310 + const SYSTEM_LIGHT = "light";
311 + const mq = window.matchMedia("(prefers-color-scheme: dark)");
312 + const systemTheme = () => mq.matches ? SYSTEM_DARK : SYSTEM_LIGHT;
313 + const apply = (theme) =>
314 + document.documentElement.setAttribute("data-theme", theme || systemTheme());
315 + apply(localStorage.getItem("phx:theme"));
316 + mq.addEventListener("change", () => {
317 + if (!localStorage.getItem("phx:theme")) apply(null);
318 + });
319 + window.addEventListener("storage", (e) => {
320 + if (e.key === "phx:theme") apply(e.newValue);
321 + });
322 + })();
323 + </script>
324 + <style>
325 + body {
326 + background: var(--color-base-100);
327 + color: var(--color-base-content);
328 + font-family: ui-monospace, "B612 Mono", SFMono-Regular, Menlo, monospace;
329 + font-size: 14px;
330 + line-height: 1.6;
331 + margin: 0 auto;
332 + max-width: 60rem;
333 + min-height: 100vh;
334 + padding: 2rem 1rem;
335 + }
336 + h1 { font-size: 1rem; font-weight: 600; margin: 0 0 .25rem; }
337 + h1 .path { opacity: .55; }
338 + p.sub { margin: 0 0 1.5rem; opacity: .6; font-size: .8rem; }
339 + table { border-collapse: collapse; width: 100%; }
340 + td { padding: .25rem .75rem .25rem 0; border-bottom: 1px solid var(--color-base-300); }
341 + td.n { text-align: right; white-space: nowrap; opacity: .6; font-size: .8rem; }
342 + a { color: inherit; text-decoration: none; }
343 + a:hover { text-decoration: underline; }
344 + a.dir { color: var(--color-primary); }
345 + </style></head><body>
346 + <h1>#{esc(label)}<span class="path">#{esc(title)}</span></h1>
347 + <p class="sub">Debian repository · apt reads this tree; you can browse it.</p>
348 + <table>
349 + """,
350 + parent,
351 + rows,
352 + "</table></body></html>\n"
353 + ]
354 + end
355 +
356 + # Digest-aware, so the stylesheet resolves in a release the same way
357 + # the app layout's does.
358 + defp stylesheet_path, do: GitGudWeb.Endpoint.static_path("/assets/css/app.css")
359 +
360 + defp format_size(nil), do: ""
361 + defp format_size(bytes) when bytes < 1024, do: "#{bytes} B"
362 + defp format_size(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
363 + defp format_size(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
364 +
365 + defp format_date(nil), do: ""
366 + defp format_date(at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M")
367 +
368 + defp esc(value) do
369 + value
370 + |> to_string()
371 + |> String.replace("&", "&amp;")
372 + |> String.replace("<", "&lt;")
373 + |> String.replace(">", "&gt;")
374 + |> String.replace("\"", "&quot;")
375 + end
376 +
173 377 # ── the indices ─────────────────────────────────────────────────────
174 378
175 379 # Every .deb across `repos`, flattened with the fields the indices
modified lib/git_gud/packages/usage.ex
+8 −0
@@ -352,6 +352,14 @@ defmodule GitGud.Packages.Usage do
352 352 login __token__
353 353 password <ggrt_…>
354 354 """
355 + ),
356 + step(
357 + "Browse it in a browser — same tree apt reads, same permissions",
358 + "shell",
359 + """
360 + #{namespace_url(ctx, "deb")}/ # everything #{ctx.handle} publishes
361 + #{url}/ # just this repository
362 + """
355 363 )
356 364 ],
357 365 publish: [
added test/git_gud_web/apt_browse_test.exs
+285 −0
@@ -0,0 +1,285 @@
1 +defmodule GitGudWeb.AptBrowseTest do
2 + @moduledoc """
3 + The Debian repository as a browsable tree.
4 +
5 + A real mirror is clickable all the way down; ours only answered exact
6 + paths, so you couldn't see what was published without already knowing
7 + its name.
8 +
9 + Two properties are worth pinning. The listing must never advertise a
10 + path that 404s — it is derived from what the repository actually
11 + serves, and a test walks every link to prove it. And it must never
12 + show a repository the caller couldn't have read directly.
13 + """
14 + use GitGudWeb.ConnCase, async: false
15 +
16 + alias GitGud.Packages
17 + alias GitGud.Repositories
18 + alias GitGud.Repositories.Storage
19 +
20 + import GitGud.AccountsFixtures
21 + import GitGud.ForgeFixtures
22 +
23 + defp deb(fields) do
24 + control = Enum.map_join(fields, fn {k, v} -> "#{k}: #{v}\n" end)
25 +
26 + ar([
27 + {"debian-binary", "2.0\n"},
28 + {"control.tar.gz", :zlib.gzip(tar([{~c"./control", control}]))},
29 + {"data.tar.gz", :zlib.gzip(tar([{~c"./usr/bin/thing", "#!/bin/sh\n"}]))}
30 + ])
31 + end
32 +
33 + defp tar(members) do
34 + path = Path.join(System.tmp_dir!(), "t-#{System.unique_integer([:positive])}.tar")
35 + :ok = :erl_tar.create(String.to_charlist(path), members, [])
36 + File.read!(path)
37 + end
38 +
39 + defp ar(members) do
40 + body =
41 + Enum.map_join(members, fn {name, data} ->
42 + String.pad_trailing(name, 16) <>
43 + String.pad_trailing("0", 12) <>
44 + String.pad_trailing("0", 6) <>
45 + String.pad_trailing("0", 6) <>
46 + String.pad_trailing("100644", 8) <>
47 + String.pad_trailing(Integer.to_string(byte_size(data)), 10) <>
48 + <<0x60, 0x0A>> <> data <> if(rem(byte_size(data), 2) == 1, do: "\n", else: "")
49 + end)
50 +
51 + "!<arch>\n" <> body
52 + end
53 +
54 + defp publish(repo, package, opts \\ []) do
55 + version = Keyword.get(opts, :version, "1.0.0-1")
56 + arch = Keyword.get(opts, :arch, "amd64")
57 +
58 + fields = [
59 + Package: package,
60 + Version: version,
61 + Architecture: arch,
62 + Maintainer: "Someone <someone@example.com>",
63 + Description: "the #{package} package"
64 + ]
65 +
66 + {:ok, _p, _v, _f} =
67 + Packages.publish_file(repo, "deb", package, version, %{
68 + filename: "#{package}_#{version}_#{arch}.deb",
69 + body: deb(fields),
70 + metadata: %{
71 + "dist" => Keyword.get(opts, :dist, "stable"),
72 + "component" => "main",
73 + "architecture" => arch,
74 + "control" => Map.new(fields, fn {k, v} -> {to_string(k), v} end)
75 + }
76 + })
77 +
78 + :ok
79 + end
80 +
81 + setup do
82 + {user, repo} = repository_fixture(%{visibility: "public"})
83 + publish(repo, "widget")
84 +
85 + {:ok, user: user, repo: repo, root: "/pkg/deb/#{Storage.owner_handle(user)}/#{repo.name}"}
86 + end
87 +
88 + describe "the tree" do
89 + test "the root lists dists, pool and the key", %{conn: conn, root: root} do
90 + conn = get(conn, root <> "/")
91 +
92 + assert conn.status == 200
93 + assert get_resp_header(conn, "content-type") == ["text/html; charset=utf-8"]
94 + assert conn.resp_body =~ "dists/"
95 + assert conn.resp_body =~ "pool/"
96 + assert conn.resp_body =~ "key.asc"
97 + end
98 +
99 + test "a suite lists its index files and components", %{conn: conn, root: root} do
100 + body = get(conn, root <> "/dists/stable/").resp_body
101 +
102 + assert body =~ "InRelease"
103 + assert body =~ "Release.gpg"
104 + assert body =~ "main/"
105 + end
106 +
107 + test "an architecture lists the Packages files", %{conn: conn, root: root} do
108 + body = get(conn, root <> "/dists/stable/main/binary-amd64/").resp_body
109 +
110 + assert body =~ "Packages"
111 + assert body =~ "Packages.xz"
112 + end
113 +
114 + test "the pool is navigable down to the .deb, with its size", %{
115 + conn: conn,
116 + repo: repo,
117 + root: root
118 + } do
119 + assert get(conn, root <> "/pool/").resp_body =~ "main/"
120 + assert get(build_conn(), root <> "/pool/main/").resp_body =~ "#{repo.name}/"
121 + assert get(build_conn(), root <> "/pool/main/#{repo.name}/").resp_body =~ "w/"
122 +
123 + leaf =
124 + get(build_conn(), root <> "/pool/main/#{repo.name}/w/widget/").resp_body
125 +
126 + assert leaf =~ "widget_1.0.0-1_amd64.deb"
127 + # A size column, not just a name.
128 + assert leaf =~ ~r/\d+(\.\d+)? (B|KB|MB)/
129 + end
130 +
131 + test "every directory offers a parent link except the root", %{conn: conn, root: root} do
132 + refute get(conn, root <> "/").resp_body =~ ">../<"
133 + assert get(build_conn(), root <> "/dists/").resp_body =~ ">../<"
134 + end
135 +
136 + test "a path that is neither a file nor a directory is still a 404", %{
137 + conn: conn,
138 + root: root
139 + } do
140 + assert get(conn, root <> "/nonsense/").status == 404
141 + assert get(build_conn(), root <> "/dists/nosuchsuite/").status == 404
142 + assert get(build_conn(), root <> "/pool/main/nosuchrepo/").status == 404
143 + end
144 + end
145 +
146 + describe "the listing never advertises a path that 404s" do
147 + test "walking every link from the root reaches only things that exist", %{
148 + conn: conn,
149 + repo: repo,
150 + root: root
151 + } do
152 + publish(repo, "libthing", arch: "all")
153 + publish(repo, "gadget", dist: "unstable")
154 +
155 + # Breadth-first over every href the tree offers, following only
156 + # links below this repository's root.
157 + {visited, checked} = walk([root <> "/"], MapSet.new(), 0)
158 +
159 + assert checked > 10, "the crawl should have covered the whole tree, saw #{checked}"
160 + assert MapSet.size(visited) > 10
161 +
162 + # Sanity: the crawl actually reached the leaves.
163 + assert Enum.any?(visited, &String.ends_with?(&1, ".deb"))
164 + assert Enum.any?(visited, &String.ends_with?(&1, "/InRelease"))
165 + assert Enum.any?(visited, &String.ends_with?(&1, "/Packages.xz"))
166 +
167 + _ = conn
168 + end
169 +
170 + defp walk([], visited, checked), do: {visited, checked}
171 +
172 + defp walk([path | rest], visited, checked) do
173 + if MapSet.member?(visited, path) do
174 + walk(rest, visited, checked)
175 + else
176 + conn = get(build_conn(), path)
177 +
178 + assert conn.status == 200,
179 + "#{path} was linked from a listing but answered #{conn.status}"
180 +
181 + visited = MapSet.put(visited, path)
182 +
183 + next =
184 + if String.ends_with?(path, "/") do
185 + conn.resp_body
186 + |> then(&Regex.scan(~r/href="([^"]+)"/, &1))
187 + |> Enum.map(fn [_, href] -> URI.parse(href).path end)
188 + # Don't climb back out through `../`.
189 + |> Enum.reject(&(byte_size(&1) <= byte_size(path)))
190 + else
191 + []
192 + end
193 +
194 + walk(rest ++ next, visited, checked + 1)
195 + end
196 + end
197 + end
198 +
199 + describe "permissions" do
200 + test "a private repository's tree challenges an anonymous browser", %{conn: conn} do
201 + {user, private} = repository_fixture(%{visibility: "private"})
202 + publish(private, "secret")
203 +
204 + root = "/pkg/deb/#{Storage.owner_handle(user)}/#{private.name}"
205 + conn = get(conn, root <> "/")
206 +
207 + assert conn.status == 401
208 + assert [_] = get_resp_header(conn, "www-authenticate")
209 + end
210 +
211 + test "and serves it to a token holder", %{conn: conn} do
212 + {user, private} = repository_fixture(%{visibility: "private"})
213 + publish(private, "secret")
214 + {:ok, _t, token} = Packages.create_registry_token(private, "ro", ["pull"])
215 +
216 + root = "/pkg/deb/#{Storage.owner_handle(user)}/#{private.name}"
217 +
218 + conn =
219 + conn
220 + |> put_req_header("authorization", token)
221 + |> get(root <> "/pool/main/#{private.name}/s/secret/")
222 +
223 + assert conn.status == 200
224 + assert conn.resp_body =~ "secret_1.0.0-1_amd64.deb"
225 + end
226 +
227 + test "a namespace listing hides repositories the browser can't read" do
228 + owner = user_fixture()
229 +
230 + {:ok, org} =
231 + GitGud.Organizations.create_organization(owner, %{
232 + "handle" => "org#{System.unique_integer([:positive])}",
233 + "name" => "Org"
234 + })
235 +
236 + {:ok, open} =
237 + Repositories.create_repository_for_org(org, owner, %{
238 + "name" => unique_repo_name(),
239 + "visibility" => "public"
240 + })
241 +
242 + {:ok, closed} =
243 + Repositories.create_repository_for_org(org, owner, %{
244 + "name" => unique_repo_name(),
245 + "visibility" => "private"
246 + })
247 +
248 + publish(open, "public-thing")
249 + publish(closed, "secret-thing")
250 +
251 + root = "/pkg/deb/#{org.handle}/_all"
252 +
253 + anon = get(build_conn(), root <> "/pool/main/").resp_body
254 + assert anon =~ "#{open.name}/"
255 + refute anon =~ "#{closed.name}/"
256 +
257 + # …and the path itself is not merely hidden from the index.
258 + assert get(build_conn(), root <> "/pool/main/#{closed.name}/").status == 404
259 + end
260 + end
261 +
262 + describe "apt is unaffected" do
263 + test "the index files it fetches are still their own content types", %{
264 + conn: conn,
265 + root: root
266 + } do
267 + release = get(conn, root <> "/dists/stable/Release")
268 + assert get_resp_header(release, "content-type") == ["text/plain; charset=utf-8"]
269 + assert release.resp_body =~ "Suite: stable"
270 +
271 + xz = get(build_conn(), root <> "/dists/stable/main/binary-amd64/Packages.xz")
272 + assert get_resp_header(xz, "content-type") == ["application/x-xz"]
273 + end
274 +
275 + test "the pool path from the index is the file, not a listing", %{conn: conn, root: root} do
276 + index = get(conn, root <> "/dists/stable/main/binary-amd64/Packages").resp_body
277 + [_, filename] = Regex.run(~r/^Filename: (\S+)$/m, index)
278 +
279 + served = get(build_conn(), root <> "/" <> filename)
280 +
281 + assert served.status == 200
282 + refute served.resp_body =~ "<!DOCTYPE html>"
283 + end
284 + end
285 +end

Parents: 2782d61