neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Package registries phase 0: the substrate under all four formats
d70ff9f · Gabriel Morell · 2026-09-11 05:13
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