neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Package registries phase 4: apt, with a Sequoia NIF
6bfb936 · Gabriel Morell · 2026-09-11 14:43
Message
{commit_body(@commit)}
Files changed
modified
.gitignore
+10
−0
| 57 | 57 | # notes. A shared .claude/settings.json, if one is ever added, stays |
| 58 | 58 | # tracked. |
| 59 | 59 | /.claude/settings.local.json |
| 60 | + | |
| 61 | +# Rust build artifacts for the NIF crates, and the shared objects | |
| 62 | +# Rustler copies out of them — `mix compile` rebuilds both, and the | |
| 63 | +# Docker build compiles them from source. | |
| 64 | +# | |
| 65 | +# (native/gitgud_gix/target and priv/native/gitgud_gix.so predate this | |
| 66 | +# rule and stay tracked, so they still churn on every build. | |
| 67 | +# Untracking them is its own change, not this one's to make.) | |
| 68 | +/native/*/target/ | |
| 69 | +/priv/native/ |
modified
Dockerfile
+6
−2
| 70 | 70 | |
| 71 | 71 | COPY lib lib |
| 72 | 72 | |
| 73 | −# Rust source for the gitgud_gix NIF — Rustler invokes `cargo` from | |
| 74 | −# this directory during `mix compile`, so it must be present. | |
| 73 | +# Rust source for the NIF crates (gitgud_gix, gitgud_apt) — Rustler | |
| 74 | +# invokes `cargo` from this directory during `mix compile`, so it must | |
| 75 | +# be present. gitgud_apt links libssl/libcrypto, which `libssl-dev` | |
| 76 | +# above already provides for the builder and the runner's `openssl` | |
| 77 | +# package provides at runtime — sequoia's OpenSSL backend was chosen | |
| 78 | +# precisely so neither image needs a new package. | |
| 75 | 79 | COPY native native |
| 76 | 80 | |
| 77 | 81 | # Compile the release |
modified
lib/git_gud/packages.ex
+25
−1
| 375 | 375 | lazy generation is what keeps "publish something" from needing a |
| 376 | 376 | separate provisioning step. |
| 377 | 377 | |
| 378 | + The key *kind* follows the format: Hex verifies an RSA signature | |
| 379 | + against a PEM public key, apt verifies an OpenPGP signature against | |
| 380 | + an armored certificate. The field names say "pem" for both because | |
| 381 | + the storage is the same; only the bytes differ. | |
| 382 | + | |
| 378 | 383 | Generation races are resolved by the unique index: a loser re-reads |
| 379 | 384 | the winner's row rather than clobbering it, because a public key that |
| 380 | 385 | changed underneath a consumer is worse than a wasted keygen. |
| 387 | 392 | end |
| 388 | 393 | |
| 389 | 394 | defp generate_signing_key(%Repository{id: rid} = repo, format) do |
| 390 | − {private_pem, public_pem} = Token.generate_keypair() | |
| 395 | + {:ok, public_pem, private_pem} = generate_keypair(repo, format) | |
| 391 | 396 | {:ok, wrapped} = GitGud.Secrets.encrypt(private_pem) |
| 392 | 397 | |
| 393 | 398 | %SigningKey{repository_id: rid} |
| 413 | 418 | end |
| 414 | 419 | end |
| 415 | 420 | |
| 421 | + # apt needs an OpenPGP certificate, which OTP cannot produce — see | |
| 422 | + # `GitGud.Packages.Apt.Native`. Everything else wants RSA in PEM, | |
| 423 | + # which is the machinery the OCI token signer already has. | |
| 424 | + defp generate_keypair(%Repository{} = repo, "deb") do | |
| 425 | + repo = Repo.preload(repo, [:owner, :organization]) | |
| 426 | + handle = GitGud.Repositories.Storage.repo_handle(repo) | |
| 427 | + user_id = "#{handle}/#{repo.name} (GitGud package registry)" | |
| 428 | + | |
| 429 | + case GitGud.Packages.Apt.Native.generate_key(user_id) do | |
| 430 | + {:ok, {public, secret}} -> {:ok, public, secret} | |
| 431 | + {:error, reason} -> {:error, reason} | |
| 432 | + end | |
| 433 | + end | |
| 434 | + | |
| 435 | + defp generate_keypair(%Repository{}, _format) do | |
| 436 | + {private_pem, public_pem} = Token.generate_keypair() | |
| 437 | + {:ok, public_pem, private_pem} | |
| 438 | + end | |
| 439 | + | |
| 416 | 440 | defp unwrap_key(%SigningKey{} = key) do |
| 417 | 441 | case GitGud.Secrets.decrypt(%{ |
| 418 | 442 | ciphertext: key.private_key_ciphertext, |
added
lib/git_gud/packages/apt/native.ex
+82
−0
| 1 | +defmodule GitGud.Packages.Apt.Native do | |
| 2 | + @moduledoc """ | |
| 3 | + The two things the apt repository needs that OTP cannot do, via the | |
| 4 | + `native/gitgud_apt` Rustler NIF. | |
| 5 | + | |
| 6 | + **OpenPGP signatures.** apt requires a signed `Release`, and | |
| 7 | + `:public_key` speaks PKCS and X.509 — OpenPGP is a different packet | |
| 8 | + format with its own key material, and nothing in the Erlang | |
| 9 | + distribution implements it. This is the only format in the registry | |
| 10 | + that needed a NIF; the rest get by on `:crypto` and `:public_key`. | |
| 11 | + | |
| 12 | + **xz and zstd.** A `.deb`'s control member is `control.tar.xz` on | |
| 13 | + anything since squeeze and `control.tar.zst` on Ubuntu, and | |
| 14 | + `:erl_tar` handles gzip and nothing else. The same encoder produces | |
| 15 | + the `Packages.xz` a Debian repository is expected to offer. | |
| 16 | + | |
| 17 | + ## Why these backends | |
| 18 | + | |
| 19 | + `sequoia-openpgp` defaults to Nettle, which would put `libnettle` | |
| 20 | + and `libgmp` in the runtime image. This project ships k8s manifests; | |
| 21 | + a NIF that changes the image's package list is a deployment change | |
| 22 | + wearing a dependency's clothes. The pure-Rust backend needs no | |
| 23 | + system libraries at all, but sequoia gates it behind | |
| 24 | + `allow-variable-time-crypto`, and this code holds a private key. The | |
| 25 | + OpenSSL backend links libssl and libcrypto, which are already in | |
| 26 | + both images — the builder installs `libssl-dev` for gix and the | |
| 27 | + runner installs `openssl` for the Erlang runtime — and is | |
| 28 | + constant-time. So: OpenSSL, and no image change at all. | |
| 29 | + | |
| 30 | + The compression crates (`lzma-rs`, `ruzstd`) are pure Rust for the | |
| 31 | + same reason: no new system libraries. | |
| 32 | + | |
| 33 | + Keys and signatures cross the boundary as armored ASCII, so nothing | |
| 34 | + on the Elixir side handles OpenPGP packets. | |
| 35 | + """ | |
| 36 | + | |
| 37 | + use Rustler, otp_app: :git_gud, crate: "gitgud_apt" | |
| 38 | + | |
| 39 | + @doc """ | |
| 40 | + Generate a signing certificate for `user_id`. Returns | |
| 41 | + `{:ok, {public_armored, secret_armored}}` — the public half goes in | |
| 42 | + a client's `/etc/apt/keyrings`, the secret half is stored encrypted | |
| 43 | + and never leaves the forge. | |
| 44 | + """ | |
| 45 | + @spec generate_key(String.t()) :: {:ok, {String.t(), String.t()}} | {:error, String.t()} | |
| 46 | + def generate_key(_user_id), do: :erlang.nif_error(:nif_not_loaded) | |
| 47 | + | |
| 48 | + @doc """ | |
| 49 | + Clearsign `data` — the `InRelease` shape, signature and signed text | |
| 50 | + in one file, which is what apt prefers. | |
| 51 | + """ | |
| 52 | + @spec sign_clearsign(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()} | |
| 53 | + def sign_clearsign(_secret_armored, _data), do: :erlang.nif_error(:nif_not_loaded) | |
| 54 | + | |
| 55 | + @doc """ | |
| 56 | + Produce a detached armored signature — the `Release.gpg` shape, | |
| 57 | + which older apt falls back to when `InRelease` is absent. | |
| 58 | + """ | |
| 59 | + @spec sign_detached(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()} | |
| 60 | + def sign_detached(_secret_armored, _data), do: :erlang.nif_error(:nif_not_loaded) | |
| 61 | + | |
| 62 | + @doc """ | |
| 63 | + Verify a detached signature. Not used to serve anything — it lets | |
| 64 | + tests assert what apt will assert. | |
| 65 | + """ | |
| 66 | + @spec verify_detached(String.t(), String.t(), String.t()) :: | |
| 67 | + {:ok, boolean()} | {:error, String.t()} | |
| 68 | + def verify_detached(_public_armored, _data, _signature_armored), | |
| 69 | + do: :erlang.nif_error(:nif_not_loaded) | |
| 70 | + | |
| 71 | + @doc "Decompress an xz stream." | |
| 72 | + @spec xz_decompress(binary()) :: {:ok, binary()} | {:error, String.t()} | |
| 73 | + def xz_decompress(_data), do: :erlang.nif_error(:nif_not_loaded) | |
| 74 | + | |
| 75 | + @doc "Compress to xz." | |
| 76 | + @spec xz_compress(binary()) :: {:ok, binary()} | {:error, String.t()} | |
| 77 | + def xz_compress(_data), do: :erlang.nif_error(:nif_not_loaded) | |
| 78 | + | |
| 79 | + @doc "Decompress a zstd stream." | |
| 80 | + @spec zstd_decompress(binary()) :: {:ok, binary()} | {:error, String.t()} | |
| 81 | + def zstd_decompress(_data), do: :erlang.nif_error(:nif_not_loaded) | |
| 82 | +end |
modified
lib/git_gud/packages/format.ex
+2
−1
| 48 | 48 | @default_adapters %{ |
| 49 | 49 | "pypi" => GitGud.Packages.Formats.Pypi, |
| 50 | 50 | "hex" => GitGud.Packages.Formats.Hex, |
| 51 | − "cargo" => GitGud.Packages.Formats.Cargo | |
| 51 | + "cargo" => GitGud.Packages.Formats.Cargo, | |
| 52 | + "deb" => GitGud.Packages.Formats.Deb | |
| 52 | 53 | } |
| 53 | 54 | |
| 54 | 55 | @typedoc """ |
added
lib/git_gud/packages/formats/deb.ex
+354
−0
Click to load diff…
added
lib/git_gud/packages/formats/deb/control.ex
+177
−0
Click to load diff…
modified
lib/git_gud_web/live/repo_live/packages.ex
+14
−0
Click to load diff…
added
native/gitgud_apt/Cargo.lock
+1470
−0
Click to load diff…
added
native/gitgud_apt/Cargo.toml
+49
−0
Click to load diff…
added
native/gitgud_apt/src/lib.rs
+273
−0
Click to load diff…
added
test/git_gud_web/apt_registry_test.exs
+401
−0
Click to load diff…
Parents: 173e2a6