Package registries phase 0: the substrate under all four formats

d70ff9f · Gabriel Morell · 2026-09-11 05:13

20 files +1182 -6
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages.ex
+192 −1
@@ -13,14 +13,22 @@ defmodule GitGud.Packages do
13 13
14 14 import Ecto.Query, warn: false
15 15
16 + alias GitGud.Packages.Format
16 17 alias GitGud.Packages.Package
18 + alias GitGud.Packages.PackageFile
17 19 alias GitGud.Packages.PackageVersion
18 20 alias GitGud.Packages.RegistryToken
19 21 alias GitGud.Repo
20 22 alias GitGud.Repositories.Repository
23 + alias GitGud.Storage
21 24
22 25 @registry_token_prefix "ggrt_"
23 26
27 + # Artifact bytes for every non-OCI format. Keys are content-addressed
28 + # (see `storage_key/3`), so the same wheel pushed to two repositories
29 + # is stored twice but a re-push is stored once.
30 + @artifact_bucket "gitgud-packages"
31 +
24 32 # ── registry deploy tokens ───────────────────────────────────────────
25 33
26 34 @doc "Repo-scoped registry tokens, by name."
@@ -35,7 +43,9 @@ defmodule GitGud.Packages do
35 43 plaintext (`ggrt_…`) is only available here — we store its hash.
36 44 """
37 45 def create_registry_token(%Repository{id: rid}, name, scopes) do
38 − plaintext = @registry_token_prefix <> (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false))
46 + plaintext =
47 + @registry_token_prefix <>
48 + (:crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false))
39 49
40 50 %RegistryToken{repository_id: rid}
41 51 |> RegistryToken.changeset(%{name: name, token_hash: token_hash(plaintext), scopes: scopes})
@@ -171,4 +181,185 @@ defmodule GitGud.Packages do
171 181 end)
172 182 end
173 183 end
184 +
185 + # ── artifact-backed formats (pypi / hex / cargo / deb) ───────────────
186 +
187 + @doc "Packages in `repo` served by one registry format."
188 + def list_packages(%Repository{id: rid}, format) when is_binary(format) do
189 + Package
190 + |> where([p], p.repository_id == ^rid and p.format == ^format)
191 + |> order_by([p], asc: p.name)
192 + |> Repo.all()
193 + end
194 +
195 + @doc """
196 + Look a package up the way its ecosystem's clients do — through the
197 + normalized name, so `Flask_Login`, `flask-login` and `FLASK.LOGIN`
198 + all reach the same row on PyPI.
199 + """
200 + def get_package(%Repository{id: rid}, format, name) when is_binary(name) do
201 + normalized = Format.normalize(format, name)
202 +
203 + Repo.get_by(Package,
204 + repository_id: rid,
205 + format: format,
206 + normalized_name: normalized
207 + )
208 + end
209 +
210 + @doc "One version of a package, or nil."
211 + def get_version(%Package{id: pid}, version) when is_binary(version),
212 + do: Repo.get_by(PackageVersion, package_id: pid, version: version)
213 +
214 + @doc "Files attached to a version, oldest upload first."
215 + def list_files(%PackageVersion{id: vid}) do
216 + PackageFile
217 + |> where([f], f.package_version_id == ^vid)
218 + |> order_by([f], asc: f.id)
219 + |> Repo.all()
220 + end
221 +
222 + @doc """
223 + Every file of every version of `package`, newest version first, with
224 + its version preloaded. This is the shape an index document wants:
225 + one flat list of downloadable artifacts.
226 + """
227 + def list_package_files(%Package{id: pid}) do
228 + from(f in PackageFile,
229 + join: v in PackageVersion,
230 + on: v.id == f.package_version_id,
231 + where: v.package_id == ^pid,
232 + order_by: [desc: v.pushed_at, asc: f.id],
233 + preload: [package_version: v]
234 + )
235 + |> Repo.all()
236 + end
237 +
238 + @doc "Look up a single file of a package by filename."
239 + def get_package_file(%Package{id: pid}, filename) when is_binary(filename) do
240 + from(f in PackageFile,
241 + join: v in PackageVersion,
242 + on: v.id == f.package_version_id,
243 + where: v.package_id == ^pid and f.filename == ^filename,
244 + preload: [package_version: v]
245 + )
246 + |> Repo.one()
247 + end
248 +
249 + @doc """
250 + Publish one artifact.
251 +
252 + Creates the package and version rows if they are new, writes the
253 + bytes to `GitGud.Storage`, and records the file. Idempotent on
254 + `(version, filename)`: re-uploading identical bytes is a no-op,
255 + re-uploading *different* bytes under a filename that already exists
256 + is refused — every ecosystem here treats a published filename as
257 + immutable.
258 +
259 + `attrs` takes `:filename`, `:body`, and optionally `:content_type`,
260 + `:metadata` (per-file, for the index), `:version_metadata` and
261 + `:uploader`.
262 + """
263 + def publish_file(%Repository{id: rid} = repo, format, name, version, attrs) do
264 + filename = Map.fetch!(attrs, :filename)
265 + body = Map.fetch!(attrs, :body)
266 + sha256 = Base.encode16(:crypto.hash(:sha256, body), case: :lower)
267 + key = storage_key(rid, format, sha256)
268 +
269 + with {:ok, package} <- upsert_package(repo, format, name),
270 + {:ok, package, ver} <- upsert_version(package, version, attrs),
271 + :ok <- ensure_replaceable(ver, filename, sha256),
272 + :ok <- Storage.put(@artifact_bucket, key, body, content_type: attrs[:content_type]) do
273 + file =
274 + case Repo.get_by(PackageFile, package_version_id: ver.id, filename: filename) do
275 + nil -> %PackageFile{package_version_id: ver.id}
276 + existing -> existing
277 + end
278 + |> PackageFile.changeset(%{
279 + filename: filename,
280 + content_type: attrs[:content_type] || Format.adapter_content_type(format, filename),
281 + size: byte_size(body),
282 + sha256: sha256,
283 + md5: Base.encode16(:crypto.hash(:md5, body), case: :lower),
284 + storage_bucket: @artifact_bucket,
285 + storage_key: key,
286 + metadata: attrs[:metadata] || %{},
287 + uploader_id: attrs[:uploader] && attrs[:uploader].id
288 + })
289 + |> Repo.insert_or_update()
290 +
291 + case file do
292 + {:ok, file} -> {:ok, package, ver, file}
293 + {:error, _} = err -> err
294 + end
295 + end
296 + end
297 +
298 + # A filename is a permanent, public identity in all four ecosystems —
299 + # pip and cargo both cache by it, so letting it point at new bytes is
300 + # a supply-chain hazard, not a convenience.
301 + defp ensure_replaceable(%PackageVersion{id: vid}, filename, sha256) do
302 + case Repo.get_by(PackageFile, package_version_id: vid, filename: filename) do
303 + nil -> :ok
304 + %PackageFile{sha256: ^sha256} -> :ok
305 + %PackageFile{} -> {:error, :file_exists}
306 + end
307 + end
308 +
309 + defp upsert_package(%Repository{id: rid}, format, name) do
310 + normalized = Format.normalize(format, name)
311 +
312 + case Repo.get_by(Package, repository_id: rid, format: format, normalized_name: normalized) do
313 + nil ->
314 + %Package{repository_id: rid}
315 + |> Package.changeset(%{format: format, kind: format, name: name})
316 + |> Repo.insert()
317 +
318 + existing ->
319 + {:ok, existing}
320 + end
321 + end
322 +
323 + defp upsert_version(%Package{} = package, version, attrs) do
324 + row =
325 + case Repo.get_by(PackageVersion, package_id: package.id, version: version) do
326 + nil -> %PackageVersion{package_id: package.id}
327 + existing -> existing
328 + end
329 +
330 + changes = %{
331 + version: version,
332 + metadata: attrs[:version_metadata] || row.metadata || %{},
333 + pushed_at: attrs[:pushed_at] || DateTime.utc_now(:second)
334 + }
335 +
336 + with {:ok, ver} <-
337 + row |> PackageVersion.artifact_changeset(changes) |> Repo.insert_or_update() do
338 + {:ok, package} = package |> Package.changeset(%{latest_version: version}) |> Repo.update()
339 + {:ok, package, ver}
340 + end
341 + end
342 +
343 + @doc "Read an artifact's bytes back out of storage."
344 + def read_file(%PackageFile{storage_bucket: b, storage_key: k}), do: Storage.get(b, k)
345 +
346 + @doc """
347 + Mark a version yanked or unyanked. Yanking never removes bytes —
348 + a build that already pinned the version must keep resolving.
349 + """
350 + def set_yanked(%PackageVersion{} = version, yanked?, reason \\ nil) do
351 + version
352 + |> PackageVersion.artifact_changeset(%{yanked: yanked?, yanked_reason: reason})
353 + |> Repo.update()
354 + end
355 +
356 + @doc "Bucket artifacts are stored in."
357 + def artifact_bucket, do: @artifact_bucket
358 +
359 + # Content-addressed, sharded on the first two hex characters the way
360 + # the LFS store is, so no single directory fans out unboundedly on
361 + # filesystem backends.
362 + defp storage_key(repo_id, format, sha256) do
363 + Enum.join([repo_id, format, binary_part(sha256, 0, 2), sha256], "/")
364 + end
174 365 end
added lib/git_gud/packages/auth.ex
+152 −0
@@ -0,0 +1,152 @@
1 +defmodule GitGud.Packages.Auth do
2 + @moduledoc """
3 + Authentication for the non-OCI package registries.
4 +
5 + `RegistryToken` is already format-agnostic — a `ggrt_…` string
6 + scoped to one repository with `pull`/`push`. What differs per
7 + ecosystem is only how the publish tool *presents* it:
8 +
9 + twine Authorization: Basic base64("__token__:ggrt_…")
10 + pip Authorization: Basic base64("<user>:<secret>") (or in the URL)
11 + mix Authorization: ggrt_… (bare, no scheme)
12 + cargo Authorization: ggrt_… (bare, no scheme)
13 + apt Authorization: Basic … (from /etc/apt/auth.conf)
14 +
15 + So credential extraction tries, in order: HTTP Basic (either half may
16 + carry the secret, since `__token__` occupies the username), a bare
17 + `Authorization` value, and `Bearer`. Each candidate is then checked
18 + against the registry tokens, the user API tokens (`ggp_…`) and
19 + finally an email + password pair.
20 + """
21 +
22 + alias GitGud.Accounts
23 + alias GitGud.Accounts.User
24 + alias GitGud.Packages
25 + alias GitGud.Repositories.Repository
26 +
27 + @typedoc """
28 + Who is making the request.
29 +
30 + * `{:user, %User{}}` — a forge account, via password or `ggp_…`
31 + * `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
32 + * `:anonymous`
33 + """
34 + @type principal :: {:user, User.t()} | {:token, integer(), [String.t()]} | :anonymous
35 +
36 + @doc """
37 + Identify the caller from the request's `Authorization` header.
38 + Always succeeds — an unauthenticated or unrecognised request is
39 + `:anonymous`, and authorization is a separate decision.
40 + """
41 + @spec authenticate(Plug.Conn.t()) :: principal()
42 + def authenticate(conn) do
43 + conn
44 + |> credentials()
45 + |> Enum.find_value(:anonymous, &verify/1)
46 + end
47 +
48 + # Every credential shape the request might be carrying, most specific
49 + # first. `{username, secret}`; `username` is nil where the scheme
50 + # doesn't carry one.
51 + defp credentials(conn) do
52 + case Plug.Conn.get_req_header(conn, "authorization") do
53 + ["Basic " <> b64 | _] ->
54 + case decode_basic(b64) do
55 + {:ok, username, secret} ->
56 + # twine puts the token in the password with `__token__` as
57 + # the username; some tools do the reverse.
58 + [{username, secret}, {nil, secret}, {nil, username}]
59 +
60 + :error ->
61 + []
62 + end
63 +
64 + ["Bearer " <> secret | _] ->
65 + [{nil, secret}]
66 +
67 + [raw | _] ->
68 + [{nil, String.trim(raw)}]
69 +
70 + [] ->
71 + []
72 + end
73 + end
74 +
75 + defp decode_basic(b64) do
76 + with {:ok, decoded} <- Base.decode64(b64),
77 + [username, secret] <- String.split(decoded, ":", parts: 2) do
78 + {:ok, username, secret}
79 + else
80 + _ -> :error
81 + end
82 + end
83 +
84 + defp verify({username, secret}) do
85 + case Packages.verify_registry_token(secret) do
86 + {:ok, repo_id, scopes} ->
87 + {:token, repo_id, scopes}
88 +
89 + :error ->
90 + case Accounts.verify_personal_access_token(secret) do
91 + {:ok, %User{} = user, _scopes} ->
92 + {:user, user}
93 +
94 + :error ->
95 + with true <- is_binary(username),
96 + %User{} = user <- Accounts.get_user_by_email_and_password(username, secret) do
97 + {:user, user}
98 + else
99 + _ -> nil
100 + end
101 + end
102 + end
103 + end
104 +
105 + @doc """
106 + May `principal` perform `action` (`:read` or `:write`) on `repo`?
107 +
108 + Reads follow repository visibility; writes are the owner's, or a
109 + `ggrt_…` minted for that repository with the matching scope.
110 + """
111 + @spec authorize(principal(), Repository.t(), :read | :write) :: boolean()
112 + def authorize({:token, repo_id, scopes}, %Repository{id: repo_id}, action) do
113 + scope_for(action) in scopes
114 + end
115 +
116 + # A token minted for a different repository is worth no more than
117 + # an anonymous request.
118 + def authorize({:token, _other, _}, %Repository{} = repo, action),
119 + do: authorize(:anonymous, repo, action)
120 +
121 + def authorize({:user, %User{} = user}, %Repository{} = repo, action) do
122 + repo = GitGud.Repo.preload(repo, [:owner, :organization])
123 +
124 + cond do
125 + user.id == repo.owner_id -> true
126 + action == :read -> repo.visibility in ["public", "internal"]
127 + true -> org_writer?(repo, user)
128 + end
129 + end
130 +
131 + def authorize(:anonymous, %Repository{visibility: vis}, :read), do: vis == "public"
132 + def authorize(:anonymous, %Repository{}, :write), do: false
133 +
134 + defp scope_for(:read), do: "pull"
135 + defp scope_for(:write), do: "push"
136 +
137 + defp org_writer?(%Repository{organization: %_{} = org}, %User{} = user),
138 + do: GitGud.Organizations.member?(org, user)
139 +
140 + defp org_writer?(_repo, _user), do: false
141 +
142 + @doc """
143 + The `WWW-Authenticate` challenge to send with a 401.
144 +
145 + `mix` and `cargo` never follow a challenge — they send their token
146 + or fail — but `pip` and `apt` both retry against Basic, so this is
147 + what makes a private index usable from a bare URL.
148 + """
149 + def challenge(format) do
150 + ~s(Basic realm="gitgud #{format} registry", charset="UTF-8")
151 + end
152 +end
added lib/git_gud/packages/format.ex
+165 −0
@@ -0,0 +1,165 @@
1 +defmodule GitGud.Packages.Format do
2 + @moduledoc """
3 + The one thing that actually differs between package registries.
4 +
5 + PyPI, Hex, Cargo and apt share everything that was expensive to
6 + build: blob storage (`GitGud.Storage`), namespacing (`Package` /
7 + `PackageVersion` / `PackageFile`, per repository), auth tokens
8 + (`GitGud.Packages.RegistryToken`), and a UI. What differs is the
9 + *index shape* a client fetches and the *upload framing* a publish
10 + tool sends. That is this behaviour, and there is one implementation
11 + per format — the same move `GitGud.Storage` makes for backends.
12 +
13 + Everything is served under `/pkg/:format/:owner/:name/*rest`;
14 + `GitGudWeb.PackageRegistryController` resolves the repository,
15 + authenticates, and hands the remaining path to the adapter.
16 +
17 + ## `index/2`
18 +
19 + Serves every read: index documents, metadata, and artifact
20 + downloads. The `ctx` carries the path below the repository root plus
21 + the raw `Plug.Conn`, because several formats negotiate on request
22 + headers — PEP 691 picks a serialisation off `Accept`, Cargo's sparse
23 + index revalidates with `If-None-Match`.
24 +
25 + ## `store/3`
26 +
27 + Serves every write, not just uploads: the `ctx` carries the HTTP
28 + method, so Cargo's `DELETE …/yank` and `PUT …/unyank` land here too.
29 + The third argument is the already-extracted upload body — either a
30 + `%Plug.Upload{}` (twine's multipart) or a raw binary (Cargo's
31 + length-prefixed octet-stream).
32 +
33 + ## `content_type/1`
34 +
35 + The media type to serve a stored artifact with, given its filename.
36 + Clients rarely care, but `pip` and `cargo` both log it.
37 + """
38 +
39 + alias GitGud.Repositories.Repository
40 +
41 + @formats ~w(oci pypi hex cargo deb)
42 +
43 + # Format identifier → adapter module. `oci` is absent on purpose: zot
44 + # is the authoritative store for container images and serves the
45 + # distribution API itself, so `oci` exists only as a `format` value
46 + # the packages UI can list alongside everything else. Each remaining
47 + # format appears here as its adapter lands.
48 + @default_adapters %{}
49 +
50 + @typedoc """
51 + Request context handed to an adapter.
52 +
53 + * `:path` — path segments below `/pkg/:format/:owner/:name`
54 + * `:conn` — the live `Plug.Conn`, for header negotiation
55 + * `:method` — `"GET"`, `"PUT"`, `"POST"`, `"DELETE"`
56 + * `:trailing_slash?` — whether the raw request path ended in `/`
57 + (Plug's segment list drops it, and PEP 503 makes it load-bearing)
58 + * `:base_url` — absolute URL of this repository's registry root,
59 + no trailing slash
60 + * `:principal` — the authenticated principal, see
61 + `GitGud.Packages.Auth`
62 + """
63 + @type ctx :: %{
64 + path: [String.t()],
65 + conn: Plug.Conn.t(),
66 + method: String.t(),
67 + trailing_slash?: boolean(),
68 + base_url: String.t(),
69 + principal: term()
70 + }
71 +
72 + @typedoc """
73 + What an adapter hands back from `index/2`.
74 +
75 + * `{:ok, content_type, body}` — render it
76 + * `{:ok, content_type, body, headers}` — …with extra response
77 + headers (`ETag`, `Cache-Control`)
78 + * `{:blob, package_file}` — serve the stored artifact
79 + * `{:redirect, url_or_path}` — 301
80 + * `:not_modified` — 304, the client's validator still holds
81 + * `{:error, reason}` — mapped to a status by the controller
82 + """
83 + @type index_result ::
84 + {:ok, String.t(), iodata()}
85 + | {:ok, String.t(), iodata(), [{String.t(), String.t()}]}
86 + | {:blob, GitGud.Packages.PackageFile.t()}
87 + | {:redirect, String.t()}
88 + | :not_modified
89 + | {:error, term()}
90 +
91 + @typedoc """
92 + What an adapter hands back from `store/3`. The body is the
93 + format's own success document — Cargo wants a `warnings` object,
94 + twine is happy with an empty 200.
95 + """
96 + @type store_result ::
97 + {:ok, String.t(), iodata()}
98 + | {:reply, integer(), String.t(), iodata()}
99 + | {:error, term()}
100 +
101 + @callback index(Repository.t(), ctx()) :: index_result()
102 + @callback store(Repository.t(), ctx(), term()) :: store_result()
103 + @callback content_type(String.t()) :: String.t()
104 +
105 + @doc """
106 + The ecosystem's canonical form of a package name. Optional — a
107 + format that doesn't specify one gets case folding.
108 + """
109 + @callback normalize(String.t()) :: String.t()
110 +
111 + @optional_callbacks normalize: 1
112 +
113 + @doc "Format identifiers this forge knows about."
114 + def formats, do: @formats
115 +
116 + @doc """
117 + The live adapter table. Defaults to every format this build knows
118 + how to serve; a deployment that only wants, say, Hex can narrow it:
119 +
120 + config :git_gud, :package_formats, %{"hex" => GitGud.Packages.Formats.Hex}
121 +
122 + A format that isn't in the table 404s, which is a better answer than
123 + serving an index nobody meant to publish.
124 + """
125 + def adapters, do: Application.get_env(:git_gud, :package_formats, @default_adapters)
126 +
127 + @doc "Resolve a format identifier to its adapter module."
128 + def adapter(format) when is_binary(format) do
129 + case Map.fetch(adapters(), format) do
130 + {:ok, mod} -> {:ok, mod}
131 + :error -> :error
132 + end
133 + end
134 +
135 + def adapter(_), do: :error
136 +
137 + @doc """
138 + Canonical form of a package name for `format`, used for every
139 + lookup. Each ecosystem specifies its own; where one doesn't, case
140 + folding is the safe floor.
141 + """
142 + def normalize(format, name) do
143 + case adapter(format) do
144 + {:ok, mod} ->
145 + if Code.ensure_loaded?(mod) and function_exported?(mod, :normalize, 1),
146 + do: mod.normalize(name),
147 + else: String.downcase(name)
148 +
149 + :error ->
150 + String.downcase(name)
151 + end
152 + end
153 +
154 + @doc """
155 + Media type to serve `filename` with, asked of `format`'s adapter.
156 + Falls back to `application/octet-stream`, which is what every client
157 + here treats an unknown artifact as anyway.
158 + """
159 + def adapter_content_type(format, filename) do
160 + case adapter(format) do
161 + {:ok, mod} -> mod.content_type(filename)
162 + :error -> "application/octet-stream"
163 + end
164 + end
165 +end
modified lib/git_gud/packages/package.ex
+30 −2
@@ -1,13 +1,24 @@
1 1 defmodule GitGud.Packages.Package do
2 + @moduledoc """
3 + A named package within a repository's registry namespace.
4 +
5 + `format` says which registry protocol serves it (`oci`, `pypi`,
6 + `hex`, `cargo`, `deb`); `normalized_name` is the form that format's
7 + clients canonicalise to and the only thing lookups go through.
8 + `kind` predates `format` and stays for the OCI rows' display label.
9 + """
2 10 use Ecto.Schema
3 11 import Ecto.Changeset
4 12
13 + alias GitGud.Packages.Format
5 14 alias GitGud.Packages.PackageVersion
6 15 alias GitGud.Repositories.Repository
7 16
8 17 schema "packages" do
18 + field :format, :string, default: "oci"
9 19 field :kind, :string, default: "container"
10 20 field :name, :string
21 + field :normalized_name, :string
11 22 field :latest_version, :string
12 23
13 24 belongs_to :repository, Repository
@@ -18,9 +29,26 @@ defmodule GitGud.Packages.Package do
18 29
19 30 def changeset(package, attrs) do
20 31 package
21 − |> cast(attrs, [:kind, :name, :latest_version])
32 + |> cast(attrs, [:format, :kind, :name, :normalized_name, :latest_version])
22 33 |> validate_required([:kind, :name])
23 34 |> validate_length(:name, max: 200)
24 − |> unique_constraint([:repository_id, :kind, :name])
35 + |> validate_inclusion(:format, Format.formats())
36 + |> put_normalized_name()
37 + |> unique_constraint([:repository_id, :format, :normalized_name])
38 + end
39 +
40 + # The normalized name is derived, not supplied: letting a caller set
41 + # it independently of `name` is how you get two rows that a client
42 + # sees as one package.
43 + defp put_normalized_name(changeset) do
44 + format = get_field(changeset, :format) || "oci"
45 +
46 + case get_field(changeset, :name) do
47 + name when is_binary(name) ->
48 + put_change(changeset, :normalized_name, Format.normalize(format, name))
49 +
50 + _ ->
51 + changeset
52 + end
25 53 end
26 54 end
added lib/git_gud/packages/package_file.ex
+58 −0
@@ -0,0 +1,58 @@
1 +defmodule GitGud.Packages.PackageFile do
2 + @moduledoc """
3 + One uploaded artifact: a wheel, an sdist, a Hex `.tar`, a `.crate`,
4 + a `.deb`.
5 +
6 + A version usually has more than one — PyPI publishes an sdist plus a
7 + wheel per platform, apt publishes a `.deb` per architecture — which
8 + is why files hang off `PackageVersion` rather than being folded into
9 + it.
10 +
11 + Bytes live in `GitGud.Storage` under `storage_bucket`/`storage_key`;
12 + only the checksum and the index-relevant metadata live here. Keys are
13 + content-addressed by `sha256`, so re-uploading identical bytes is
14 + free.
15 + """
16 + use Ecto.Schema
17 +
18 + import Ecto.Changeset
19 +
20 + alias GitGud.Accounts.User
21 + alias GitGud.Packages.PackageVersion
22 +
23 + schema "package_files" do
24 + field :filename, :string
25 + field :content_type, :string
26 + field :size, :integer, default: 0
27 + field :sha256, :string
28 + field :md5, :string
29 + field :storage_bucket, :string
30 + field :storage_key, :string
31 + field :metadata, :map, default: %{}
32 +
33 + belongs_to :package_version, PackageVersion
34 + belongs_to :uploader, User
35 +
36 + timestamps(type: :utc_datetime)
37 + end
38 +
39 + @doc false
40 + def changeset(file, attrs) do
41 + file
42 + |> cast(attrs, [
43 + :filename,
44 + :content_type,
45 + :size,
46 + :sha256,
47 + :md5,
48 + :storage_bucket,
49 + :storage_key,
50 + :metadata,
51 + :uploader_id
52 + ])
53 + |> validate_required([:filename, :sha256, :storage_bucket, :storage_key])
54 + |> validate_length(:filename, max: 300)
55 + |> validate_format(:sha256, ~r/\A[0-9a-f]{64}\z/, message: "must be lowercase hex SHA-256")
56 + |> unique_constraint([:package_version_id, :filename])
57 + end
58 +end
modified lib/git_gud/packages/package_version.ex
+41 −3
@@ -1,9 +1,18 @@
1 1 defmodule GitGud.Packages.PackageVersion do
2 + @moduledoc """
3 + A single published version of a `Package`.
4 +
5 + OCI versions are a manifest `digest` mirrored from zot. Every other
6 + format's bytes arrive as one or more `PackageFile` rows, and the
7 + version itself carries only what the index needs: the ecosystem's
8 + own metadata document and whether it has been yanked.
9 + """
2 10 use Ecto.Schema
3 11 import Ecto.Changeset
4 12
5 13 alias GitGud.Accounts.User
6 14 alias GitGud.Packages.Package
15 + alias GitGud.Packages.PackageFile
7 16
8 17 schema "package_versions" do
9 18 field :version, :string
@@ -11,20 +20,49 @@ defmodule GitGud.Packages.PackageVersion do
11 20 field :size, :integer, default: 0
12 21 field :media_type, :string
13 22 field :manifest_doc, :map
23 + field :metadata, :map, default: %{}
24 + field :yanked, :boolean, default: false
25 + field :yanked_reason, :string
14 26 field :pushed_at, :utc_datetime
15 27
16 28 belongs_to :package, Package
17 29 belongs_to :pusher, User
30 + has_many :files, PackageFile
18 31
19 32 timestamps(type: :utc_datetime)
20 33 end
21 34
35 + @doc """
36 + Changeset for an OCI version mirrored from zot, where the manifest
37 + digest is the identity of the thing.
38 + """
22 39 def changeset(version, attrs) do
23 40 version
24 − |> cast(attrs, [:version, :digest, :size, :media_type, :manifest_doc, :pushed_at])
25 − |> validate_required([:version, :digest])
26 − |> validate_length(:version, max: 200)
41 + |> artifact_changeset(attrs)
42 + |> validate_required([:digest])
27 43 |> validate_length(:digest, max: 100)
44 + end
45 +
46 + @doc """
47 + Changeset for a version whose bytes live in `package_files`. No
48 + digest: a PyPI release with an sdist and four wheels has five
49 + checksums and no single one that names the release.
50 + """
51 + def artifact_changeset(version, attrs) do
52 + version
53 + |> cast(attrs, [
54 + :version,
55 + :digest,
56 + :size,
57 + :media_type,
58 + :manifest_doc,
59 + :metadata,
60 + :yanked,
61 + :yanked_reason,
62 + :pushed_at
63 + ])
64 + |> validate_required([:version])
65 + |> validate_length(:version, max: 200)
28 66 |> unique_constraint([:package_id, :version])
29 67 end
30 68 end
added lib/git_gud_web/controllers/package_registry_controller.ex
+217 −0
@@ -0,0 +1,217 @@
1 +defmodule GitGudWeb.PackageRegistryController do
2 + @moduledoc """
3 + The single HTTP entry point for every non-OCI package registry.
4 +
5 + /pkg/:format/:owner/:name/*rest
6 +
7 + This controller does the four things that are the same regardless of
8 + format — resolve the repository, authenticate, authorize, and turn an
9 + adapter's return value into a response — and hands `*rest` to the
10 + `GitGud.Packages.Format` implementation for the rest.
11 +
12 + Reads (`GET`/`HEAD`) go to `index/2`, everything else to `store/3`;
13 + Cargo's yank is a `DELETE` and unyank a `PUT`, so "write" is the
14 + useful split rather than "upload".
15 +
16 + A 401 carries a Basic challenge. `pip` and `apt` retry against it,
17 + which is what makes a private index usable from a plain URL; `mix`
18 + and `cargo` ignore it and just fail, which is also correct — they
19 + were always going to send a token or nothing.
20 + """
21 +
22 + use GitGudWeb, :controller
23 +
24 + alias GitGud.Packages
25 + alias GitGud.Packages.Auth
26 + alias GitGud.Packages.Format
27 + alias GitGud.Packages.PackageFile
28 + alias GitGud.Repositories
29 +
30 + # Upload bodies we read ourselves. Well above a fat manylinux wheel;
31 + # anything larger belongs in LFS or the OCI registry.
32 + @max_upload_bytes 200_000_000
33 +
34 + def index(conn, params), do: dispatch(conn, params, :read)
35 + def store(conn, params), do: dispatch(conn, params, :write)
36 +
37 + defp dispatch(conn, %{"format" => format, "owner" => owner, "name" => name} = params, action) do
38 + with {:ok, mod} <- adapter(format),
39 + {:ok, repo} <- fetch_repo(owner, name),
40 + principal = Auth.authenticate(conn),
41 + true <- Auth.authorize(principal, repo, action) do
42 + ctx = %{
43 + path: List.wrap(params["rest"]),
44 + conn: conn,
45 + method: conn.method,
46 + trailing_slash?: String.ends_with?(conn.request_path, "/"),
47 + base_url: base_url(format, owner, name),
48 + principal: principal
49 + }
50 +
51 + case action do
52 + :read -> respond(conn, format, mod.index(repo, ctx))
53 + :write -> write(conn, format, mod, repo, ctx)
54 + end
55 + else
56 + :error ->
57 + send_error(conn, format, 404, "unknown package format")
58 +
59 + # A private repository is indistinguishable from a missing one to
60 + # a caller who can't read it — but an anonymous caller gets a
61 + # challenge first, since they may simply not have sent a token.
62 + false ->
63 + if Auth.authenticate(conn) == :anonymous do
64 + conn
65 + |> put_resp_header("www-authenticate", Auth.challenge(format))
66 + |> send_error(format, 401, "authentication required")
67 + else
68 + send_error(conn, format, 403, "forbidden")
69 + end
70 +
71 + {:error, :not_found} ->
72 + send_error(conn, format, 404, "no such repository")
73 + end
74 + end
75 +
76 + defp write(conn, format, mod, repo, ctx) do
77 + case read_upload(conn) do
78 + {:ok, upload, conn} ->
79 + respond(conn, format, mod.store(repo, %{ctx | conn: conn}, upload))
80 +
81 + {:error, :too_large} ->
82 + send_error(conn, format, 413, "upload too large")
83 + end
84 + end
85 +
86 + # The body arrives one of two ways. Multipart (twine) has already
87 + # been decoded into params by `Plug.Parsers`, so hand the adapter the
88 + # params map. Anything else (Cargo's length-prefixed octet-stream,
89 + # Hex's raw tarball) passed straight through `pass: ["*/*"]` and is
90 + # still on the socket.
91 + defp read_upload(conn, acc \\ []) do
92 + cond do
93 + conn.body_params != %{} and not match?(%Plug.Conn.Unfetched{}, conn.body_params) ->
94 + {:ok, conn.body_params, conn}
95 +
96 + true ->
97 + case Plug.Conn.read_body(conn, length: 8_000_000) do
98 + {:ok, chunk, conn} ->
99 + {:ok, IO.iodata_to_binary(Enum.reverse([chunk | acc])), conn}
100 +
101 + {:more, chunk, conn} ->
102 + acc = [chunk | acc]
103 +
104 + if IO.iodata_length(acc) > @max_upload_bytes,
105 + do: {:error, :too_large},
106 + else: read_upload(conn, acc)
107 +
108 + {:error, _} ->
109 + {:ok, IO.iodata_to_binary(Enum.reverse(acc)), conn}
110 + end
111 + end
112 + end
113 +
114 + # ── rendering ───────────────────────────────────────────────────────
115 +
116 + defp respond(conn, format, result) do
117 + case result do
118 + {:ok, content_type, body} ->
119 + send_body(conn, 200, content_type, body)
120 +
121 + {:ok, content_type, body, headers} ->
122 + Enum.reduce(headers, conn, fn {k, v}, acc -> put_resp_header(acc, k, v) end)
123 + |> send_body(200, content_type, body)
124 +
125 + {:reply, status, content_type, body} ->
126 + send_body(conn, status, content_type, body)
127 +
128 + {:blob, %PackageFile{} = file} ->
129 + send_blob(conn, format, file)
130 +
131 + {:redirect, to} ->
132 + conn |> put_resp_header("location", to) |> send_resp(301, "")
133 +
134 + :not_modified ->
135 + send_resp(conn, 304, "")
136 +
137 + {:error, :not_found} ->
138 + send_error(conn, format, 404, "not found")
139 +
140 + {:error, :not_acceptable} ->
141 + send_error(conn, format, 406, "no acceptable serialisation")
142 +
143 + {:error, :file_exists} ->
144 + send_error(conn, format, 409, "a file with that name is already published")
145 +
146 + {:error, {status, message}} when is_integer(status) ->
147 + send_error(conn, format, status, message)
148 +
149 + {:error, reason} ->
150 + send_error(conn, format, 400, error_message(reason))
151 + end
152 + end
153 +
154 + defp send_body(conn, status, content_type, body) do
155 + conn
156 + |> put_resp_content_type_raw(content_type)
157 + |> send_resp(status, body)
158 + end
159 +
160 + # `put_resp_content_type/2` appends `; charset=utf-8`, which corrupts
161 + # the exact media types PEP 691 negotiates on.
162 + defp put_resp_content_type_raw(conn, content_type),
163 + do: put_resp_header(conn, "content-type", content_type)
164 +
165 + defp send_blob(conn, format, %PackageFile{} = file) do
166 + case Packages.read_file(file) do
167 + {:ok, body} ->
168 + conn
169 + |> put_resp_header("etag", ~s("#{file.sha256}"))
170 + |> put_resp_header("content-disposition", ~s(attachment; filename="#{file.filename}"))
171 + |> send_body(200, file.content_type || "application/octet-stream", body)
172 +
173 + {:error, _} ->
174 + send_error(conn, format, 404, "artifact bytes are missing from storage")
175 + end
176 + end
177 +
178 + # Error shape is per-ecosystem: Cargo parses `{"errors": [...]}` and
179 + # prints `detail` back to the user, which is worth quite a lot when a
180 + # publish fails. Everything else gets plain text.
181 + defp send_error(conn, "cargo", status, message) do
182 + conn
183 + |> put_resp_content_type_raw("application/json")
184 + |> send_resp(status, Jason.encode!(%{"errors" => [%{"detail" => message}]}))
185 + end
186 +
187 + defp send_error(conn, _format, status, message) do
188 + conn
189 + |> put_resp_content_type_raw("text/plain; charset=utf-8")
190 + |> send_resp(status, message)
191 + end
192 +
193 + defp error_message(reason) when is_binary(reason), do: reason
194 + defp error_message(reason) when is_atom(reason), do: to_string(reason)
195 + defp error_message(reason), do: inspect(reason)
196 +
197 + # ── lookups ─────────────────────────────────────────────────────────
198 +
199 + defp adapter(format), do: Format.adapter(format)
200 +
201 + defp fetch_repo(owner, name) do
202 + {:ok, Repositories.get_repository_by_path!(owner, name)}
203 + rescue
204 + Ecto.NoResultsError -> {:error, :not_found}
205 + end
206 +
207 + # The registry root for this repository, absolute, no trailing slash.
208 + # Index documents have to emit absolute download URLs, and the host
209 + # a client reached us on is the only one we know works for them.
210 + defp base_url(format, owner, name) do
211 + root =
212 + Application.get_env(:git_gud, :package_registry_url) ||
213 + GitGudWeb.Endpoint.url()
214 +
215 + String.trim_trailing(root, "/") <> "/pkg/#{format}/#{owner}/#{name}"
216 + end
217 +end
modified lib/git_gud_web/router.ex
+18 −0
@@ -137,6 +137,24 @@ defmodule GitGudWeb.Router do
137 137 post "/zot", PackagesWebhookController, :notify
138 138 end
139 139
140 + # Private package registries — PyPI, Hex, Cargo, apt. One route for
141 + # all of them; `GitGudWeb.PackageRegistryController` resolves the
142 + # repository and hands `*rest` to the format's adapter.
143 + #
144 + # The `:raw` pipeline on purpose: there is no `accepts` gate to pass
145 + # (package clients send every Accept header there is, and PEP 691
146 + # negotiates on it inside the adapter), and upload bodies must reach
147 + # the adapter unparsed.
148 + scope "/pkg", GitGudWeb do
149 + pipe_through :raw
150 +
151 + get "/:format/:owner/:name/*rest", PackageRegistryController, :index
152 + head "/:format/:owner/:name/*rest", PackageRegistryController, :index
153 + post "/:format/:owner/:name/*rest", PackageRegistryController, :store
154 + put "/:format/:owner/:name/*rest", PackageRegistryController, :store
155 + delete "/:format/:owner/:name/*rest", PackageRegistryController, :store
156 + end
157 +
140 158 # Disk-backed storage. Token-gated; the URL is the capability.
141 159 # When the storage impl is S3/Garage, these routes are unused.
142 160 scope "/_storage", GitGudWeb do
added priv/repo/migrations/20260911000000_add_package_formats.exs
+96 −0
@@ -0,0 +1,96 @@
1 +defmodule GitGud.Repo.Migrations.AddPackageFormats do
2 + use Ecto.Migration
3 +
4 + @moduledoc """
5 + Phase 0 of the private package registries plan: the substrate the
6 + PyPI/Hex/Cargo/apt adapters all sit on.
7 +
8 + Three changes:
9 +
10 + * `packages.format` — which registry protocol serves this package.
11 + Everything that exists today came from zot, so it backfills to
12 + `oci`. Uniqueness moves from `(repository_id, kind, name)` to
13 + `(repository_id, format, normalized_name)`, which is what every
14 + adapter actually looks up by.
15 +
16 + * `packages.normalized_name` — the form the ecosystem canonicalises
17 + to (PEP 503 for PyPI, lowercase for Cargo, verbatim for Hex/OCI).
18 + Stored rather than computed so lookups hit an index.
19 +
20 + * `package_files` — one row per uploaded artifact. OCI keeps its
21 + bytes in zot, but every other format hands us a file (a wheel, a
22 + `.tar`, a `.crate`, a `.deb`) that lives in `GitGud.Storage`.
23 + A version may have several (sdist + wheels, per-arch debs).
24 + """
25 +
26 + def up do
27 + alter table(:packages) do
28 + add :format, :string, null: false, default: "oci", size: 20
29 + add :normalized_name, :string, size: 200
30 + end
31 +
32 + execute "UPDATE packages SET format = 'oci', normalized_name = lower(name)"
33 +
34 + alter table(:packages) do
35 + modify :normalized_name, :string, null: false, size: 200
36 + end
37 +
38 + drop unique_index(:packages, [:repository_id, :kind, :name])
39 + create unique_index(:packages, [:repository_id, :format, :normalized_name])
40 + create index(:packages, [:format, :normalized_name])
41 +
42 + # Non-OCI versions are identified by the artifact's own checksum,
43 + # which lives on `package_files` — there is no single manifest
44 + # digest to require here.
45 + alter table(:package_versions) do
46 + modify :digest, :string, null: true
47 + add :metadata, :map
48 + add :yanked, :boolean, null: false, default: false
49 + add :yanked_reason, :string
50 + end
51 +
52 + create table(:package_files) do
53 + add :package_version_id, references(:package_versions, on_delete: :delete_all), null: false
54 + add :filename, :string, null: false, size: 300
55 + add :content_type, :string
56 + add :size, :bigint, null: false, default: 0
57 + # Lowercase hex. SHA-256 is the floor for every format we serve —
58 + # apt clients are required to reject MD5Sum/SHA1 for security.
59 + add :sha256, :string, null: false, size: 64
60 + # Only populated where a format's index demands it (apt's optional
61 + # MD5Sum field, Hex's inner tarball checksum).
62 + add :md5, :string, size: 32
63 + add :storage_bucket, :string, null: false
64 + add :storage_key, :string, null: false
65 + # Per-format extras: requires-python and the PEP 592 yank reason,
66 + # a crate's dependency table, a .deb's control stanza.
67 + add :metadata, :map
68 + add :uploader_id, references(:users, on_delete: :nilify_all)
69 +
70 + timestamps(type: :utc_datetime)
71 + end
72 +
73 + create unique_index(:package_files, [:package_version_id, :filename])
74 + create index(:package_files, [:sha256])
75 + end
76 +
77 + def down do
78 + drop table(:package_files)
79 +
80 + alter table(:package_versions) do
81 + remove :yanked_reason
82 + remove :yanked
83 + remove :metadata
84 + modify :digest, :string, null: false
85 + end
86 +
87 + drop index(:packages, [:format, :normalized_name])
88 + drop unique_index(:packages, [:repository_id, :format, :normalized_name])
89 + create unique_index(:packages, [:repository_id, :kind, :name])
90 +
91 + alter table(:packages) do
92 + remove :normalized_name
93 + remove :format
94 + end
95 + end
96 +end
added test/git_gud/packages_substrate_test.exs
+213 −0
@@ -0,0 +1,213 @@
1 +defmodule GitGud.PackagesSubstrateTest do
2 + @moduledoc """
3 + Phase 0: the parts every registry format sits on, exercised without
4 + any format adapter in play.
5 + """
6 + use GitGud.DataCase, async: true
7 +
8 + alias GitGud.Packages
9 + alias GitGud.Packages.Auth
10 + alias GitGud.Packages.Format
11 +
12 + import GitGud.ForgeFixtures
13 +
14 + setup do
15 + {user, repo} = repository_fixture()
16 + {:ok, user: user, repo: repo}
17 + end
18 +
19 + describe "publish_file/5" do
20 + test "creates the package, version and file rows", %{repo: repo, user: user} do
21 + assert {:ok, pkg, ver, file} =
22 + Packages.publish_file(repo, "pypi", "Widget", "1.0.0", %{
23 + filename: "widget-1.0.0.tar.gz",
24 + body: "sdist bytes",
25 + content_type: "application/x-tar",
26 + uploader: user
27 + })
28 +
29 + assert pkg.format == "pypi"
30 + assert pkg.name == "Widget"
31 + assert pkg.latest_version == "1.0.0"
32 + assert ver.version == "1.0.0"
33 + assert file.size == byte_size("sdist bytes")
34 +
35 + assert file.sha256 ==
36 + Base.encode16(:crypto.hash(:sha256, "sdist bytes"), case: :lower)
37 +
38 + assert {:ok, "sdist bytes"} = Packages.read_file(file)
39 + end
40 +
41 + test "a second file joins the same version", %{repo: repo} do
42 + {:ok, _, _, _} =
43 + Packages.publish_file(repo, "pypi", "widget", "1.0.0", %{
44 + filename: "widget-1.0.0.tar.gz",
45 + body: "sdist"
46 + })
47 +
48 + {:ok, pkg, ver, _} =
49 + Packages.publish_file(repo, "pypi", "widget", "1.0.0", %{
50 + filename: "widget-1.0.0-py3-none-any.whl",
51 + body: "wheel"
52 + })
53 +
54 + assert [_, _] = Packages.list_files(ver)
55 + assert [ver_only] = Packages.list_versions(pkg)
56 + assert ver_only.id == ver.id
57 + end
58 +
59 + test "re-uploading identical bytes is a no-op, not a conflict", %{repo: repo} do
60 + attrs = %{filename: "widget-1.0.0.tar.gz", body: "sdist"}
61 + {:ok, _, _, first} = Packages.publish_file(repo, "pypi", "widget", "1.0.0", attrs)
62 + {:ok, _, _, second} = Packages.publish_file(repo, "pypi", "widget", "1.0.0", attrs)
63 +
64 + assert first.id == second.id
65 + end
66 +
67 + test "a published filename can never point at different bytes", %{repo: repo} do
68 + {:ok, _, _, _} =
69 + Packages.publish_file(repo, "pypi", "widget", "1.0.0", %{
70 + filename: "widget-1.0.0.tar.gz",
71 + body: "sdist"
72 + })
73 +
74 + assert {:error, :file_exists} =
75 + Packages.publish_file(repo, "pypi", "widget", "1.0.0", %{
76 + filename: "widget-1.0.0.tar.gz",
77 + body: "different sdist"
78 + })
79 + end
80 +
81 + test "the same name under two formats is two packages", %{repo: repo} do
82 + {:ok, pypi, _, _} =
83 + Packages.publish_file(repo, "pypi", "widget", "1.0.0", %{
84 + filename: "a.tar.gz",
85 + body: "a"
86 + })
87 +
88 + {:ok, cargo, _, _} =
89 + Packages.publish_file(repo, "cargo", "widget", "1.0.0", %{
90 + filename: "b.crate",
91 + body: "b"
92 + })
93 +
94 + refute pypi.id == cargo.id
95 + assert [^pypi] = Packages.list_packages(repo, "pypi")
96 + assert [^cargo] = Packages.list_packages(repo, "cargo")
97 + end
98 + end
99 +
100 + describe "lookup" do
101 + test "goes through the normalized name", %{repo: repo} do
102 + {:ok, pkg, _, _} =
103 + Packages.publish_file(repo, "cargo", "Serde", "1.0.0", %{
104 + filename: "serde-1.0.0.crate",
105 + body: "crate"
106 + })
107 +
108 + assert Packages.get_package(repo, "cargo", "serde").id == pkg.id
109 + assert Packages.get_package(repo, "cargo", "SERDE").id == pkg.id
110 + assert Packages.get_package(repo, "cargo", "nope") == nil
111 + end
112 +
113 + test "list_package_files/1 flattens versions into downloadables", %{repo: repo} do
114 + for v <- ["1.0.0", "1.1.0"] do
115 + {:ok, _, _, _} =
116 + Packages.publish_file(repo, "pypi", "widget", v, %{
117 + filename: "widget-#{v}.tar.gz",
118 + body: "sdist #{v}"
119 + })
120 + end
121 +
122 + pkg = Packages.get_package(repo, "pypi", "widget")
123 + files = Packages.list_package_files(pkg)
124 +
125 + assert length(files) == 2
126 + assert Enum.all?(files, &(&1.package_version.version in ["1.0.0", "1.1.0"]))
127 + end
128 + end
129 +
130 + describe "yanking" do
131 + test "flags the version without removing its bytes", %{repo: repo} do
132 + {:ok, pkg, ver, file} =
133 + Packages.publish_file(repo, "pypi", "widget", "1.0.0", %{
134 + filename: "widget-1.0.0.tar.gz",
135 + body: "sdist"
136 + })
137 +
138 + {:ok, _} = Packages.set_yanked(ver, true, "broken metadata")
139 +
140 + assert %{yanked: true, yanked_reason: "broken metadata"} =
141 + Packages.get_version(pkg, "1.0.0")
142 +
143 + assert {:ok, "sdist"} = Packages.read_file(file)
144 + end
145 + end
146 +
147 + describe "Format" do
148 + test "an unregistered format has no adapter" do
149 + assert Format.adapter("oci") == :error
150 + assert Format.adapter("nonsense") == :error
151 + end
152 +
153 + test "normalization falls back to case folding" do
154 + assert Format.normalize("oci", "Foo.Bar") == "foo.bar"
155 + end
156 + end
157 +
158 + describe "Auth" do
159 + test "a registry token authenticates and carries its own scopes", %{repo: repo} do
160 + {:ok, _token, plaintext} = Packages.create_registry_token(repo, "ci", ["pull", "push"])
161 +
162 + conn = conn_with_auth("Basic " <> Base.encode64("__token__:#{plaintext}"))
163 +
164 + assert {:token, repo_id, scopes} = Auth.authenticate(conn)
165 + assert repo_id == repo.id
166 + assert "push" in scopes
167 + end
168 +
169 + test "a bare Authorization value works too — mix and cargo send that", %{repo: repo} do
170 + {:ok, _token, plaintext} = Packages.create_registry_token(repo, "ci", ["pull"])
171 +
172 + assert {:token, _, ["pull"]} = Auth.authenticate(conn_with_auth(plaintext))
173 + end
174 +
175 + test "a pull-only token may not write", %{repo: repo} do
176 + {:ok, _token, plaintext} = Packages.create_registry_token(repo, "ro", ["pull"])
177 + principal = Auth.authenticate(conn_with_auth(plaintext))
178 +
179 + assert Auth.authorize(principal, repo, :read)
180 + refute Auth.authorize(principal, repo, :write)
181 + end
182 +
183 + test "a token minted for another repo is worth no more than anonymous", %{repo: repo} do
184 + {_other_user, other_repo} = repository_fixture()
185 + {:ok, _t, plaintext} = Packages.create_registry_token(other_repo, "ci", ["pull", "push"])
186 +
187 + principal = Auth.authenticate(conn_with_auth(plaintext))
188 +
189 + refute Auth.authorize(principal, %{repo | visibility: "private"}, :read)
190 + refute Auth.authorize(principal, repo, :write)
191 + end
192 +
193 + test "anonymous reads public repositories only", %{repo: repo} do
194 + assert Auth.authorize(:anonymous, %{repo | visibility: "public"}, :read)
195 + refute Auth.authorize(:anonymous, %{repo | visibility: "internal"}, :read)
196 + refute Auth.authorize(:anonymous, %{repo | visibility: "private"}, :read)
197 + refute Auth.authorize(:anonymous, %{repo | visibility: "public"}, :write)
198 + end
199 +
200 + test "the owner may write", %{repo: repo, user: user} do
201 + assert Auth.authorize({:user, user}, repo, :write)
202 + end
203 +
204 + test "no credentials at all is anonymous" do
205 + assert Auth.authenticate(Plug.Test.conn(:get, "/")) == :anonymous
206 + assert Auth.authenticate(conn_with_auth("Basic not-base64")) == :anonymous
207 + end
208 + end
209 +
210 + defp conn_with_auth(value) do
211 + Plug.Test.conn(:get, "/") |> Plug.Conn.put_req_header("authorization", value)
212 + end
213 +end

Parents: bd60adf