neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Make the Debian repository browsable
279c2d1 · Gabriel Morell · 2026-09-11 19:51
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("&", "&") | |
| 372 | + |> String.replace("<", "<") | |
| 373 | + |> String.replace(">", ">") | |
| 374 | + |> String.replace("\"", """) | |
| 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