Commit

Merge branch 'worktree-agent-ac1de8a731938ed81'

syntaqxcommitted Parents6f2a3ccd22452aBrowse files
20 files+1453−740/20 viewed
+19−9
4242 access works; one with scopes needs `packages:read` to add private crates
4343 and `packages:write` to publish and yank them.
4444
45−The token can also come from the environment, as
46−`CARGO_REGISTRIES_ACME_TOKEN` for a registry named `acme`, which is how
47−[workflows](#in-workflows) give it.
45+The token can also come from the environment instead of `cargo login`,
46+as `CARGO_REGISTRIES_ACME_TOKEN` for a registry named `acme` (the name in
47+capitals, with `-` written `_`). The `cargo:token` provider reads it from
48+there, which is how [workflows](#in-workflows) give it.
4849
4950 ## Publish
5051
149150 ## In workflows
150151
151152 A workflow's `G1T_TOKEN` is the workspace's own token for the run, and can
152−add and publish the workspace's crates. Give it to Cargo for the registry:
153+add and publish the workspace's crates. A workflow runs no `cargo login`:
154+give Cargo the registry, its credential provider and the token in the
155+environment, each named for the registry:
153156
154157 ```yaml
155158 jobs:
165168 - run: cargo publish --registry acme
166169 ```
167170
168−`CARGO_REGISTRIES_ACME_INDEX` and `CARGO_REGISTRIES_ACME_CREDENTIAL_PROVIDER`
169−are needed only when the project has no `.cargo/config.toml` naming the
170−registry.
171+| Variable | Is | Needed |
172+| --- | --- | --- |
173+| `CARGO_REGISTRIES_ACME_INDEX` | The registry's index, as `index` in `.cargo/config.toml`. | When no `.cargo/config.toml` names the registry. |
174+| `CARGO_REGISTRIES_ACME_CREDENTIAL_PROVIDER` | `cargo:token`, as `credential-provider` in `.cargo/config.toml`. | When no `.cargo/config.toml` names the provider. Without one, Cargo refuses a registry with private crates, even with the token in the environment. |
175+| `CARGO_REGISTRIES_ACME_TOKEN` | The token, for `cargo:token` to hand to the registry. | Always: `cargo publish` sends it, and Cargo sends it to read a registry with private crates. |
176+
177+With the project's `.cargo/config.toml` from [above](#set-up-cargoconfigtoml)
178+checked in, `CARGO_REGISTRIES_ACME_TOKEN` is all a workflow sets. The same
179+variables let `cargo build` and `cargo test` download the workspace's
180+private crates.
171181
172182 ## Size
173183
181191
182192 | Error | Means |
183193 | --- | --- |
184−| `401` | No token, or a wrong or expired one. Run `cargo login --registry acme` with a g1t access token. |
185−| `authenticated registries require a credential-provider to be configured` | The workspace has private crates, and Cargo has no provider for its token. Add `credential-provider = "cargo:token"` to the registry in `.cargo/config.toml`. |
194+| `401` | No token, or a wrong or expired one. Run `cargo login --registry acme` with a g1t access token, or set `CARGO_REGISTRIES_ACME_TOKEN`. |
195+| `authenticated registries require a credential-provider to be configured` | The workspace has private crates, and Cargo has no provider for its token. Add `credential-provider = "cargo:token"` to the registry in `.cargo/config.toml`, or set `CARGO_REGISTRIES_ACME_CREDENTIAL_PROVIDER=cargo:token`. |
186196 | `403` | Signed in, but your role or your token's scopes do not allow it, or the workspace is out of free package storage. The message says which. |
187197 | `404` | No such crate or version, or a private one you cannot see. |
188198 | `400` | The publish was refused: a name that is not valid or is taken, a version already published, or metadata Cargo did not send in full. The message says which. |
+50−4
122122 `credentials(PasswordCredentials::class)` reads `acmeUsername` and
123123 `acmePassword` from `gradle.properties` or from the environment as
124124 `ORG_GRADLE_PROJECT_acmeUsername` and `ORG_GRADLE_PROJECT_acmePassword`.
125−Gradle's Gradle Module Metadata (`.module`) is uploaded and served beside
126−the POM.
127125
126+Gradle uploads the jar, the POM, the sources and javadoc jars if you build
127+them, and its Gradle Module Metadata (`.module`), each with its `.md5`,
128+`.sha1`, `.sha256` and `.sha512`. The `.module` file is served beside the
129+POM, so a Gradle build that depends on the artifact reads its variants
130+(API and runtime dependencies, capabilities) from it, and Maven reads the
131+POM. Gradle's `HEAD` requests, which it makes to check files it has
132+cached (with `--refresh-dependencies`, or for a SNAPSHOT), are answered
133+with each file's size and type.
134+
128135 ## Which repository an artifact belongs to
129136
130137 The first file deployed makes the artifact. When a repository of the
182189 -DremoteRepositories=acme::default::https://g1t.sh/-/maven/acme/
183190 ```
184191
192+## Maven plugins
193+
194+A plugin is deployed like any other artifact, with `<packaging>maven-plugin</packaging>`.
195+g1t lists the plugins of each group in the group's own
196+`maven-metadata.xml` (`com/acme/maven-metadata.xml` for the group
197+`com.acme`), with the prefix each is called by: the `goalPrefix` from the
198+descriptor `maven-plugin-plugin` puts in its jar, else the one Maven
199+works out from its artifactId (`hello-maven-plugin` is `hello`).
200+
201+To call a plugin by its prefix, as `mvn hello:greet`, name its group in
202+`~/.m2/settings.xml` and the repository as a plugin repository:
203+
204+```xml
205+<settings>
206+ <pluginGroups>
207+ <pluginGroup>com.acme</pluginGroup>
208+ </pluginGroups>
209+ <profiles>
210+ <profile>
211+ <id>acme</id>
212+ <pluginRepositories>
213+ <pluginRepository>
214+ <id>acme</id>
215+ <url>https://g1t.sh/-/maven/acme/</url>
216+ </pluginRepository>
217+ </pluginRepositories>
218+ </profile>
219+ </profiles>
220+ <activeProfiles>
221+ <activeProfile>acme</activeProfile>
222+ </activeProfiles>
223+</settings>
224+```
225+
226+The group's metadata lists only the plugins the credentials' owner may
227+see. A plugin's full coordinates (`mvn com.acme:hello-maven-plugin:1.0.0:greet`)
228+work without the plugin group.
229+
185230 ## SNAPSHOTs
186231
187232 A version ending in `-SNAPSHOT` takes a new build each time it is
202247 uploaded are checked against it, and a mismatch is refused with `400`.
203248 - **`maven-metadata.xml` is made by g1t** from the versions there, so it
204249 always lists every version, with the highest as `latest` and the highest
205− that is not a SNAPSHOT as `release`. The one a build uploads is accepted
206− and not kept.
250+ that is not a SNAPSHOT as `release`, and a group's lists its
251+ [plugins](#maven-plugins). The one a build uploads is accepted and not
252+ kept.
207253 - **The deploy's last step publishes it.** Maven and Gradle upload the
208254 artifact's `maven-metadata.xml` after its files. Then each version (or
209255 SNAPSHOT build) whose POM arrived in the deploy is published: it is an
+46−4
8282 [who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package).
8383
8484 The package's page on g1t.sh shows the README the package names
85−(`PackageReadmeFile`) and the description of its highest stable version.
85+(`PackageReadmeFile`) and the description of its highest stable version,
86+and each version's downloads: every `.nupkg` restored counts for its
87+version, and the registration (`downloads` in each catalog entry) and
88+search (each version's `downloads`, and the package's `totalDownloads`)
89+say them too. Counts are approximate.
90+
91+## Symbols
92+
93+Push a symbol package beside the package, and debuggers can step into
94+its code: the feed has a symbol server that serves each PDB by the key
95+the debugger asks for. Build a `.snupkg` with the package:
96+
97+```xml
98+<PropertyGroup>
99+ <IncludeSymbols>true</IncludeSymbols>
100+ <SymbolPackageFormat>snupkg</SymbolPackageFormat>
101+</PropertyGroup>
102+```
103+
104+`dotnet pack` then writes `Acme.Http.0.3.1.snupkg` beside the `.nupkg`,
105+and `dotnet nuget push` of the `.nupkg` pushes it after the package, to
106+the feed's `SymbolPackagePublish` resource, with the same API key. A
107+symbol package is for a version already pushed; its PDBs must be portable
108+PDBs (`DebugType` `portable`, the default). A version's symbols are pushed
109+once. Its page marks the versions that have them, and the `.snupkg` is in
110+the flat container beside the `.nupkg`.
111+
112+The symbol server is at:
86113
114+```text
115+https://g1t.sh/-/nuget/<workspace>/symbols/
116+```
117+
118+Add that address as a symbol server in your debugger (in Visual Studio,
119+**Tools > Options > Debugging > Symbols**). It answers the Simple Symbol
120+Query Protocol, `symbols/<file>.pdb/<key>/<file>.pdb`, for the
121+packages the credentials' owner may see; debuggers that send no
122+credentials to a symbol server load the symbols of public packages.
123+`dotnet-symbol` sends a token with `--authenticated-server-path`:
124+
125+```sh
126+dotnet-symbol --authenticated-server-path <token> https://g1t.sh/-/nuget/acme/symbols/ -o symbols bin/Debug/net8.0/Acme.Http.dll
127+```
128+
87129 ## Restore
88130
89131 ```sh
177219 | --- | --- |
178220 | `401` | No credentials or API key, or a wrong or expired token. Check the source's username and password, or the `--api-key`. |
179221 | `403` | Signed in, but your role or your token's scopes do not allow it, or the workspace is out of free package storage. The response says which. |
180−| `404` | No such package or version, or a private one you cannot see. |
181−| `409` | That version is already pushed. Bump `Version`. |
182−| `400` | The push was refused: not a `.nupkg`, no `.nuspec` in it, or an id or version NuGet would not take. The response says which. |
222+| `404` | No such package or version, or a private one you cannot see. For a symbol package: its version is not pushed yet. |
223+| `409` | That version is already pushed, or already has symbols. Bump `Version`. |
224+| `400` | The push was refused: not a `.nupkg`, no `.nuspec` in it, an id or version NuGet would not take, or a symbol package that is not one or holds a PDB that is not portable. The response says which. |
183225 | `413` | The `.nupkg` is over 100 MB. |
+4−4
1717 | npm | `https://g1t.sh/-/npm/`, for the scope `@<workspace>` | [npm](/guides/npm/) |
1818 | Cargo | `sparse+https://g1t.sh/-/cargo/<workspace>/index/`, a registry per workspace | [Cargo](/guides/cargo/) |
1919 | Maven | `https://g1t.sh/-/maven/<workspace>/`, a repository per workspace, for Maven and Gradle | [Maven](/guides/maven/) |
20−| NuGet | `https://g1t.sh/-/nuget/<workspace>/v3/index.json`, a feed per workspace | [NuGet](/guides/nuget/) |
21−| RubyGems | `https://g1t.sh/-/rubygems/<workspace>/`, a registry per workspace, for `gem push` and Bundler | [RubyGems](/guides/rubygems/) |
20+| NuGet | `https://g1t.sh/-/nuget/<workspace>/v3/index.json`, a feed per workspace, with a symbol server | [NuGet](/guides/nuget/) |
21+| RubyGems | `https://g1t.sh/-/rubygems/<workspace>/`, a registry per workspace, for `gem push`, `gem install` and Bundler | [RubyGems](/guides/rubygems/) |
2222 | Composer | `https://g1t.sh/-/composer/<workspace>/`, from the workspace's repositories | [Composer](/guides/composer/) |
2323 | Go | `g1t.sh/<workspace>/<repo>`, straight from git | [Go modules](/guides/go/) |
2424
101101
102102 Publishing a version, deleting a version and deleting a package are
103103 [audit log](/guides/audit-log/) entries (so are deprecating an npm version,
104−yanking or unyanking a crate version, unlisting or listing a NuGet version
105−and yanking a gem version), and the events
104+yanking or unyanking a crate version, unlisting or listing a NuGet version,
105+pushing a NuGet version's symbols and yanking a gem version), and the events
106106 `package.published`, `package.version_deleted`, `package.deleted` and
107107 `package.visibility_changed`, which [webhooks](/guides/webhooks/) can be
108108 sent: a linked package's go to its repository's webhooks and its
+28−8
44 ---
55
66 Every workspace has a gem registry of its own. `gem push` publishes to it,
7−and Bundler installs from it through the compact index (`versions`,
8−`info/<gem>`), private gems included. Install with Bundler: the registry
9−serves the compact index, not the older full index (`specs.4.8.gz`) that
10−`gem install --source` reads.
7+and Bundler and `gem install` install from it, private gems included.
8+It serves both indexes RubyGems reads: the compact index Bundler uses
9+(`versions`, `info/<gem>`), and the full index (`specs.4.8.gz` and each
10+version's specification) that `gem install --source` and `gem search`
11+read.
1112
1213 ```text
1314 https://g1t.sh/-/rubygems/<workspace>/
9697 `bundle install` then reads the compact index and downloads each `.gem`,
9798 which it checks against the SHA-256 the index names.
9899
100+## Install with gem
101+
102+To install a gem and what it depends on without a `Gemfile`, name the
103+workspace's source. For private gems, put a username (any works) and a
104+token in its address:
105+
106+```sh
107+gem install http-client --source https://ada:<token>@g1t.sh/-/rubygems/acme/
108+```
109+
110+Gems it depends on from rubygems.org need that source too: add
111+`--source https://rubygems.org/` after the workspace's. To keep the
112+source, add it once with `gem sources --add <address>`.
113+
114+`gem search http --remote --source <address>` lists the workspace's gems,
115+and `gem specification http-client --remote --source <address>` shows one.
116+`--prerelease` includes pre-releases. Yanked versions are in neither.
117+
99118 ## Names and versions
100119
101120 A gem's name is letters, digits, `.`, `-` and `_`, with at least one
114133 GEM_HOST_API_KEY=<token> gem yank http-client --version 0.3.1 --host https://g1t.sh/-/rubygems/acme
115134 ```
116135
117−A yanked version leaves the index, so Bundler no longer resolves to it,
136+A yanked version leaves both indexes, so Bundler and `gem install` no
137+longer resolve to it,
118138 but its `.gem` is still downloaded for a `Gemfile.lock` that names it.
119139 Yanking needs what pushing does. The gem's page marks yanked versions, and
120140 someone with Admin on the linked repository (an owner, for the
127147
128148 | The workspace's gems | Without credentials | With credentials |
129149 | --- | --- | --- |
130−| All public | Bundler reads the index and downloads them. | The same; the key is sent to push and yank. |
131−| Some private | The registry answers `401`, and Bundler asks for credentials for the source. | Each gem the credentials' owner may see. |
150+| All public | Bundler and `gem` read the index and download them. | The same; the key is sent to push and yank. |
151+| Some private | The registry answers `401`: Bundler asks for credentials for the source, and `gem` needs them in the source's address. | Each gem the credentials' owner may see. |
132152
133153 A private gem you cannot see looks exactly like one that does not exist.
134154
163183
164184 | Error | Means |
165185 | --- | --- |
166−| `401` | No credentials or key, or a wrong or expired token. For Bundler, set the source's credentials with `bundle config set`; for `gem push`, give `GEM_HOST_API_KEY`. |
186+| `401` | No credentials or key, or a wrong or expired token. For Bundler, set the source's credentials with `bundle config set`; for `gem install`, put them in the source's address; for `gem push`, give `GEM_HOST_API_KEY`. |
167187 | `403` | Signed in, but your role or your token's scopes do not allow it, or the workspace is out of free package storage. The response says which. |
168188 | `404` | No such gem or version, or a private one you cannot see. |
169189 | `409` | That version is already pushed, or its name is taken by a gem named in another case. |
+10−0
230230 Deprecated
231231 </Badge>
232232 )}
233+ {version.symbols && (
234+ <Badge tone="neutral" title="A symbol package (.snupkg) was pushed: debuggers load its PDBs from the feed's symbol server.">
235+ Symbols
236+ </Badge>
237+ )}
233238 </div>
234239 {version.deprecated && <p className="text-xs text-muted">{version.deprecated}</p>}
235240 <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums">
236241 <span>{formatBytes(version.size)}</span>
242+ {version.downloads != null && (
243+ <span>
244+ {version.downloads.toLocaleString("en-US")} {version.downloads === 1 ? "download" : "downloads"}
245+ </span>
246+ )}
237247 {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>}
238248 {attached.length > 0 && (
239249 <span title={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}>
+6−0
140140 /// npm: why the version should no longer be used, when it is deprecated.
141141 #[serde(default)]
142142 pub deprecated: Option<String>,
143+ /// NuGet: whether a symbol package (`.snupkg`) was pushed for it.
144+ #[serde(default)]
145+ pub symbols: bool,
146+ /// NuGet: its own downloads, where they are counted by version.
147+ #[serde(default)]
148+ pub downloads: Option<u64>,
143149 }
144150
145151 #[derive(Clone, Debug, Serialize, Deserialize)]
+85−14
88 This is the design every phase builds to. Each ecosystem's own guide (apps/docs) says how to use
99 it; this says how it works.
1010
11+## Status
12+
13+What is built, as of 2026-10-07. Each protocol section below marks the same. "Checked" says what was run
14+on that day against `wrangler dev` (`services/packages/dev/`) with the tool itself, beyond unit tests.
15+
16+| Registry | Built | Not built |
17+| --- | --- | --- |
18+| Containers | Pull, push (chunked, multipart, monolithic, cross-repository mount), deletes, referrers, token exchange, anonymous pull limits; `g1t push` for layers over the request limit. | |
19+| npm | Scoped publish and install, abbreviated packuments, dist-tags, deprecate, unpublish (72 hours, or Admin), `whoami`. | Proxying unscoped packages and other scopes from the public registry. |
20+| Composer | Packages from the workspace's repositories: tags and branches as versions, dist zips made and kept by commit, backfill. | A Packagist mirror. |
21+| Cargo | Sparse index, `config.json` with `auth-required`, publish, yank and unyank, search; owners are not kept (`cargo owner` answers why). Checked with `cargo`, including a workflow-like publish and build with only environment variables. | |
22+| Go | `go get` from git: `?go-get=1` answers with `go-import`. | The module proxy at `/-/go/`. |
23+| Maven | Standard layout, releases and SNAPSHOTs, checksums (MD5, SHA-1, SHA-256, SHA-512), generated `maven-metadata.xml` per artifact, SNAPSHOT and plugin group (`mvn <prefix>:<goal>`), Gradle Module Metadata. Checked with `mvn deploy`, `mvn <prefix>:<goal>`, and Gradle `publish` and resolution (releases, SNAPSHOTs, `--refresh-dependencies`). | |
24+| NuGet | v3 feed: push, flat container, registration, search, unlist and relist, symbol packages (`.snupkg`) and a symbol server, per-version download counts. Checked with `dotnet nuget push`, `dotnet restore` and `dotnet-symbol`. | |
25+| RubyGems | `gem push`, `gem yank`, the compact index (Bundler), the full index (`specs.4.8.gz`, `latest_`, `prerelease_`, `quick/Marshal.4.8`). Checked with `gem push`, `gem install --source`, `gem search` and `gem specification --remote`. | |
26+| PyPI | | Everything. |
27+
28+Across registries: events, webhooks, the audit log, the billing meter and free limits, the
29+Packages pages and a project's packages are built. Workflows triggered by `registry_package`,
30+packages in site-wide search, and packages in the activity feed are not.
31+
1132 ## Principles
1233
1334 - **One service.** `services/packages` (Rust Worker) owns every registry: its own D1 database
6283
6384 ### Containers (OCI Distribution 1.1)
6485
86+Built.
87+
6588 - Image names: `g1t.sh/<workspace>/<name>[:tag]`, `<name>` may contain `/`. Usually the
6689 repository's name, and then linked to it.
6790 - `GET /v2/` answers 401 with `WWW-Authenticate: Bearer realm="https://g1t.sh/v2/token",
86109
87110 ### npm
88111
112+Built, except the proxy of unscoped packages.
113+
89114 - Registry `https://g1t.sh/-/npm/`; scope = workspace: `@<workspace>/<name>`.
90115 `.npmrc`: `@acme:registry=https://g1t.sh/-/npm/` and `//g1t.sh/-/npm/:_authToken=<token>`.
91116 - `GET /@scope/name` (packument, abbreviated with `Accept: application/vnd.npm.install-v1+json`),
92117 `GET` tarballs, `PUT /@scope/name` (publish: JSON with the tarball attached), dist-tags,
93118 deprecate, unpublish (within 72 hours or with Admin), `GET /-/whoami`.
94119 - Unscoped and other scopes: optionally proxied from the public registry and kept, so one
95− `.npmrc` line serves everything (later phase).
120+ `.npmrc` line serves everything (later phase; not built).
96121
97122 ### Composer
98123
124+Built, except the Packagist mirror.
125+
99126 - Per workspace: `https://g1t.sh/-/composer/<workspace>/` with `packages.json` naming
100127 `metadata-url` `/p2/%package%.json` and `available-packages`.
101128 - Versions come from **the workspace's repositories themselves**: a repository with a
104131 kept by commit. Pushing a tag publishes; nothing to upload.
105132 - Auth: `composer config --auth http-basic.g1t.sh <you> <token>` (`auth.json`).
106133 - A mirror of the public Packagist (metadata and dists kept, so installs survive its outages) is
107− a later phase.
134+ a later phase (not built).
108135
109136 ### Cargo
110137
111−- Sparse registry per workspace: `sparse+https://g1t.sh/-/cargo/<workspace>/`. `config.json`
138+Built.
139+
140+- Sparse registry per workspace: `sparse+https://g1t.sh/-/cargo/<workspace>/index/`. `config.json`
112141 (`dl`, `api`, `auth-required` for private), index files at the standard prefix paths, crate
113− downloads, `PUT /api/v1/crates/new` (publish), yank and unyank, owners.
114−- Auth: a g1t token through `cargo login --registry g1t`.
142+ downloads, `PUT /api/v1/crates/new` (publish), yank and unyank, search. Owners are not kept:
143+ who publishes is decided by the repository's or workspace's roles, and `cargo owner` answers
144+ with an error saying so.
145+- Auth: a g1t token, given to the registry's credential provider (`cargo:token`), named in
146+ `.cargo/config.toml` as `[registries.<workspace>]`: `cargo login --registry <workspace>`, or
147+ in workflows `CARGO_REGISTRIES_<WORKSPACE>_TOKEN` (with `CARGO_REGISTRIES_<WORKSPACE>_INDEX`
148+ and `CARGO_REGISTRIES_<WORKSPACE>_CREDENTIAL_PROVIDER=cargo:token` when no config names the
149+ registry). Without a provider Cargo refuses a registry with private crates.
115150
116151 ### Go
117152
153+Built from git; the module proxy is not built.
154+
118155 - `go get g1t.sh/<workspace>/<repo>` works from git: repository pages answer `?go-get=1` with the
119156 `go-import` meta tag. Private modules need `GOPRIVATE=g1t.sh/<workspace>` and a token in
120157 `.netrc` (as for git).
121158 - A module proxy at `https://g1t.sh/-/go/` (`@v/list`, `.info`, `.mod`, `.zip`) built from tags,
122− for faster and repeatable private installs (later phase).
159+ for faster and repeatable private installs (later phase; not built).
123160
124161 ### Maven
125162
163+Built, for Maven and Gradle.
164+
126165 - Per workspace: `https://g1t.sh/-/maven/<workspace>/`, the standard layout
127166 (`com/acme/web/1.0.0/web-1.0.0.jar`). A package is an artifact, named
128167 `groupId:artifactId`; a version holds every file uploaded into its
135174 and kept by digest (`checksums`); `.md5`, `.sha1`, `.sha256`, `.sha512`
136175 are answered from them, and uploaded ones are checked, not kept.
137176 - `maven-metadata.xml` is made on every read: per artifact (versions in
138− Maven's order, `latest`, `release`) and per SNAPSHOT version (the newest
139− build of each classifier and extension). Uploaded ones are accepted and
140− let go, a plugin group's included.
177+ Maven's order, `latest`, `release`), per SNAPSHOT version (the newest
178+ build of each classifier and extension), and per group (`<plugins>`: each
179+ `maven-plugin` artifact's prefix, artifactId and name, which is how
180+ `mvn <prefix>:<goal>` finds a plugin in its `<pluginGroups>`). The prefix
181+ is the `goalPrefix` of the jar's `META-INF/maven/plugin.xml`, read when
182+ the jar arrives, else Maven's from the artifactId. A path that is both an
183+ artifact's and a group's answers both. Uploaded ones are accepted and let
184+ go, a plugin group's included.
185+- Gradle: its `.module` files are kept and served like any other file, and
186+ its `HEAD` requests and SHA-256 and SHA-512 checksum uploads are
187+ answered as Maven's are.
141188 - The POM is the version's record: its coordinates must
142189 match its path, its description becomes the package's for the highest
143190 release, and on a new artifact its `<scm><url>` may link the repository.
147194
148195 ### NuGet
149196
197+Built.
198+
150199 - Per workspace: `https://g1t.sh/-/nuget/<workspace>/v3/index.json` naming
151200 `PackageBaseAddress/3.0.0` (flat container), `RegistrationsBaseUrl`
152− (one inlined page), `SearchQueryService` and `PackagePublish/2.0.0`.
201+ (one inlined page), `SearchQueryService`, `PackagePublish/2.0.0` and
202+ `SymbolPackagePublish/4.9.0`.
153203 - `dotnet nuget push`: a `PUT` of a multipart body with the `.nupkg`,
154204 `X-NuGet-ApiKey` a g1t token. The `.nuspec` (read from the zip) gives the
155205 id, version (normalized as NuGet does), description, dependency groups and
159209 nuget.org does; `POST` lists again. Unlisted versions stay in the flat
160210 container and registration (`listed: false`), not in search.
161211 - Restores use Basic auth from `nuget.config`, after a `401`.
212+- Downloads: each `.nupkg` download counts for its version (`versions.downloads`) and its
213+ package; the registration's catalog entries and search's versions name them.
214+- Symbols: `dotnet nuget push` sends the `.snupkg` beside a `.nupkg` to
215+ `api/v2/symbolpackage` after it. It must be for a version already pushed, say
216+ `SymbolsPackage` as its package type, and hold portable PDBs. The `.snupkg` is the version's
217+ file `snupkg` (also in the flat container), and each PDB its file `pdb:<file>:<key>`, the key
218+ being the PDB id's GUID (as `Guid.ToString("N")`) and `ffffffff`. The symbol server,
219+ `symbols/<file>.pdb/<key>/<file>.pdb` (the Simple Symbol Query Protocol), finds it by that
220+ name across the workspace's packages the viewer may read (`version_files_name` index). A
221+ version's symbols are pushed once (`409`). The package page marks versions with symbols.
162222
163223 ### RubyGems
164224
225+Built.
226+
165227 - Per workspace: `https://g1t.sh/-/rubygems/<workspace>/`. `gem push`
166228 (`POST /api/v1/gems`, the token as the whole `Authorization` header),
167229 `gem yank` (`DELETE /api/v1/gems/yank`), downloads at
173235 - The gem's `metadata.gz` (YAML, in the `.gem` tar) gives the name,
174236 version, platform and runtime dependencies. A version is keyed as the
175237 index writes it (`1.0.0`, `1.0.0-x86_64-linux`) and pushed once.
176−- Bundler authenticates with Basic auth from `bundle config`. The full
177− index (`specs.4.8.gz`, Marshal) is not served, so `gem install --source`
178− is not supported.
238+- The full index `gem install --source` and `gem search` read:
239+ `specs.4.8.gz` (released versions), `latest_specs.4.8.gz` (the highest of
240+ each gem and platform) and `prerelease_specs.4.8.gz`, each a gzipped
241+ Ruby Marshal 4.8 array of `[name, Gem::Version, platform]`, and
242+ `quick/Marshal.4.8/<name>-<version>[-<platform>].gemspec.rz`, the
243+ deflated Marshal of a `Gem::Specification` as its `_dump` writes it. All
244+ are made on each read from what each version keeps (`src/marshal.rs` is
245+ the writer), and read by RubyGems' `SafeMarshal`.
246+- Bundler authenticates with Basic auth from `bundle config`; `gem` with
247+ credentials in the source's address.
179248
180249 ### Later
181250
182−PyPI.
251+PyPI (not built).
183252
184253 ## Billing
185254
222291 4. **Cargo.**
223292 5. **Maven, NuGet, RubyGems.**
224293 6. **Mirrors** (Packagist, npm).
294+
295+Phases 1 to 5 are built (see [Status](#status)); 6 is not.
+4−0
6262 published_at: string;
6363 /** npm: why the version should no longer be used, when it is deprecated. */
6464 deprecated?: string | null;
65+ /** NuGet: whether a symbol package (`.snupkg`) was pushed for it. */
66+ symbols?: boolean;
67+ /** NuGet: its own downloads, where they are counted by version. */
68+ downloads?: number | null;
6569 };
6670
6771 export type PackageTag = { tag: string; digest: string; updated_at: string };
+7−0
1+-- Each version's own downloads, beside its package's: NuGet's registration
2+-- and search name them. Counted the way the package's are, approximately.
3+ALTER TABLE versions ADD COLUMN downloads INTEGER NOT NULL DEFAULT 0;
4+
5+-- The NuGet symbol server finds a PDB by the name a version keeps it under
6+-- (`pdb:<file>:<key>`), across a workspace's packages.
7+CREATE INDEX version_files_name ON version_files (name);
+10−0
8787 Ok(data)
8888 }
8989
90+/// `data`, gzipped: what RubyGems' full index files are.
91+pub fn gzip(data: &[u8]) -> Vec<u8> {
92+ let mut out = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 3];
93+ out.extend_from_slice(&miniz_oxide::deflate::compress_to_vec(data, 6));
94+ out.extend_from_slice(&crc32(data).to_le_bytes());
95+ out.extend_from_slice(&(data.len() as u32).to_le_bytes());
96+ out
97+}
98+
9099 /// The bytes of a gzip file, inflated, up to `limit`.
91100 pub fn gunzip(bytes: &[u8], limit: usize) -> Result<Vec<u8>, String> {
92101 let bad = || "The file is not gzipped.".to_owned();
200209 assert_eq!(files[1].1, b"data");
201210 assert_eq!(gunzip(files[0].1, 1024).unwrap(), b"--- !ruby/object:Gem::Specification\nname: hello\n");
202211 assert!(gunzip(b"plain text, not gzip at all", 1024).is_err());
212+ assert_eq!(gunzip(&super::gzip(b"specs"), 1024).unwrap(), b"specs");
203213 assert!(tar_files(&gem[..1538]).is_err(), "cut short");
204214 }
205215 }
+57−3
188188 /// Cargo: 1 when the version is yanked.
189189 #[serde(default)]
190190 pub yanked: u32,
191+ /// Its own downloads, counted for NuGet's.
192+ #[serde(default)]
193+ pub downloads: u64,
191194 }
192195
193196 impl VersionRow {
249252 pub media_type: Option<String>,
250253 }
251254
255+/// A file found by its name, and the package that keeps it.
256+#[derive(Clone, Debug, Deserialize)]
257+pub struct NamedFile {
258+ pub package_id: String,
259+ pub digest: String,
260+ pub size: u64,
261+}
262+
252263 /// A file's other checksums, in hex, beside its SHA-256 digest.
253264 #[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
254265 pub struct Checksums {
293304 "id, workspace, ecosystem, name, repo_id, repo_name, visibility, description, created_by, created_at, updated_at, downloads, workspace_deleted_at";
294305 /// Workspaces that are deleted, waiting to be purged or restored.
295306 const DELETED_WORKSPACES: &str = "SELECT workspace FROM packages WHERE workspace_deleted_at IS NOT NULL";
296−const VERSION_COLUMNS: &str = "id, package_id, version, digest, size, metadata, subject, published_by, published_at, deprecated, yanked";
307+const VERSION_COLUMNS: &str = "id, package_id, version, digest, size, metadata, subject, published_by, published_at, deprecated, yanked, downloads";
297308
298309 pub struct Db {
299310 pub db: D1Database,
457468 Ok(())
458469 }
459470
460− pub async fn add_downloads(&self, counts: &[(String, u64)]) -> Result<()> {
471+ /// Adds downloads to packages, and to the versions named with them.
472+ pub async fn add_downloads(&self, counts: &[((String, Option<String>), u64)]) -> Result<()> {
461473 if counts.is_empty() {
462474 return Ok(());
463475 }
464476 let mut batch = Vec::with_capacity(counts.len());
465− for (id, count) in counts {
477+ for ((id, version), count) in counts {
466478 batch.push(self.prepare("UPDATE packages SET downloads = downloads + ? WHERE id = ?", &[num(*count), text(id)])?);
479+ if let Some(version) = version {
480+ batch.push(self.prepare("UPDATE versions SET downloads = downloads + ? WHERE id = ?", &[num(*count), text(version)])?);
481+ }
467482 }
468483 self.db.batch(batch).await?;
469484 Ok(())
11921207 .results()
11931208 }
11941209
1210+ pub async fn package_by_id(&self, package_id: &str) -> Result<Option<PackageRow>> {
1211+ self.prepare(&format!("SELECT {PACKAGE_COLUMNS} FROM packages WHERE id = ?"), &[text(package_id)])?
1212+ .first(None)
1213+ .await
1214+ }
1215+
1216+ /// The files a workspace's packages of an ecosystem keep under `name`,
1217+ /// newest first: how the NuGet symbol server finds a PDB.
1218+ pub async fn files_named(&self, workspace: &str, ecosystem: &str, name: &str, limit: u32) -> Result<Vec<NamedFile>> {
1219+ self.prepare(
1220+ &format!(
1221+ "SELECT v.package_id, f.digest, f.size FROM version_files f
1222+ JOIN versions v ON v.id = f.version_id JOIN packages p ON p.id = v.package_id
1223+ WHERE f.name = ? AND p.workspace = ? AND p.ecosystem = ? AND p.workspace_deleted_at IS NULL
1224+ ORDER BY v.published_at DESC LIMIT {limit}"
1225+ ),
1226+ &[text(name), text(workspace), text(ecosystem)],
1227+ )?
1228+ .all()
1229+ .await?
1230+ .results()
1231+ }
1232+
1233+ /// A workspace's Maven artifacts of one groupId (`com.acme:*`), by
1234+ /// name: those named from `com.acme:` up to `com.acme;`, the
1235+ /// character after `:`.
1236+ pub async fn maven_group(&self, workspace: &str, group: &str, limit: u32) -> Result<Vec<PackageRow>> {
1237+ self.prepare(
1238+ &format!(
1239+ "SELECT {PACKAGE_COLUMNS} FROM packages WHERE workspace = ? AND ecosystem = 'maven' AND name >= ? AND name < ?
1240+ AND workspace_deleted_at IS NULL ORDER BY name LIMIT {limit}"
1241+ ),
1242+ &[text(workspace), text(&format!("{group}:")), text(&format!("{group};"))],
1243+ )?
1244+ .all()
1245+ .await?
1246+ .results()
1247+ }
1248+
11951249 pub async fn forget_blob(&self, digest: &str) -> Result<()> {
11961250 let d = [text(digest)];
11971251 self.db
+20−6
1717 mod digest;
1818 mod limits;
1919 mod manifest;
20+mod marshal;
2021 mod maven;
2122 mod maven_http;
2223 mod names;
6263 const SWEEP_BATCH: u32 = 200;
6364
6465 thread_local! {
65− /// Pulls counted since the last write, by package: written at most
66− /// every few seconds, so a busy image costs one write, not one a pull.
67− /// What an isolate holds when it goes away is lost: the count is
68− /// approximate.
69− static DOWNLOADS: RefCell<(HashMap<String, u64>, u64)> = RefCell::new((HashMap::new(), 0));
66+ /// Pulls counted since the last write, by package (and by version,
67+ /// where it is counted too): written at most every few seconds, so a
68+ /// busy image costs one write, not one a pull. What an isolate holds
69+ /// when it goes away is lost: the count is approximate.
70+ static DOWNLOADS: RefCell<(HashMap<DownloadKey, u64>, u64)> = RefCell::new((HashMap::new(), 0));
7071 }
7172 const DOWNLOADS_FLUSH_MS: u64 = 10_000;
73+/// A package's id, and a version's when the download counts for it too.
74+type DownloadKey = (String, Option<String>);
7275
7376 thread_local! {
7477 /// What billing allows each workspace, as asked last, and when.
285288 }
286289
287290 fn count_download(&self, package_id: &str, ctx: &Context) {
291+ self.count_downloads(package_id, None, ctx);
292+ }
293+
294+ /// A download of one version, counted for it and its package.
295+ fn count_version_download(&self, package_id: &str, version_id: &str, ctx: &Context) {
296+ self.count_downloads(package_id, Some(version_id), ctx);
297+ }
298+
299+ fn count_downloads(&self, package_id: &str, version_id: Option<&str>, ctx: &Context) {
288300 let due = DOWNLOADS.with(|counts| {
289301 let mut counts = counts.borrow_mut();
290− *counts.0.entry(package_id.to_owned()).or_default() += 1;
302+ *counts.0.entry((package_id.to_owned(), version_id.map(str::to_owned))).or_default() += 1;
291303 let now = now_ms();
292304 if now.saturating_sub(counts.1) < DOWNLOADS_FLUSH_MS {
293305 return None;
456468 } else {
457469 version.deprecated
458470 },
471+ symbols: meta["symbols"] == true,
472+ downloads: (row.package.ecosystem == "nuget").then_some(version.downloads),
459473 }
460474 })
461475 .collect();
+203−0
1+//! Ruby's Marshal format, version 4.8, written (never read): what the
2+//! RubyGems full index is made of. `specs.4.8.gz` is a marshalled array of
3+//! `[name, Gem::Version, platform]`, and each
4+//! `quick/Marshal.4.8/<gem>.gemspec.rz` a marshalled `Gem::Specification`.
5+//!
6+//! Only what those need is here: nil, booleans, small integers, strings
7+//! (UTF-8, or binary), symbols, arrays, hashes, plain objects with
8+//! instance variables, and the two kinds of custom dump RubyGems' classes
9+//! use (`marshal_dump`, which `Gem::Version` and `Gem::Requirement` use,
10+//! and `_dump`, which `Gem::Specification` uses). Symbols already written
11+//! are written again as links, as Ruby does; objects never are, which
12+//! Ruby reads the same.
13+
14+use std::collections::HashMap;
15+
16+/// A Ruby value to marshal.
17+#[derive(Clone, Debug, PartialEq)]
18+pub enum Value {
19+ Nil,
20+ Bool(bool),
21+ /// An integer between -2^31 and 2^31, which Marshal writes as a Fixnum.
22+ Int(i32),
23+ /// A UTF-8 string.
24+ Str(String),
25+ /// A binary (ASCII-8BIT) string.
26+ Bytes(Vec<u8>),
27+ Symbol(String),
28+ Array(Vec<Value>),
29+ Hash(Vec<(Value, Value)>),
30+ /// An object of `class` with these instance variables (`@name`).
31+ Object { class: String, ivars: Vec<(String, Value)> },
32+ /// What `class#marshal_dump` returned, for `class.marshal_load`.
33+ UserMarshal { class: String, data: Box<Value> },
34+ /// The bytes `class#_dump` returned, for `class._load`.
35+ UserDef { class: String, data: Vec<u8> },
36+}
37+
38+impl Value {
39+ pub fn str(text: impl Into<String>) -> Value {
40+ Value::Str(text.into())
41+ }
42+
43+ /// A string, or nil for none.
44+ pub fn opt(text: Option<&str>) -> Value {
45+ text.map_or(Value::Nil, Value::str)
46+ }
47+}
48+
49+/// `value`, marshalled, with the 4.8 header.
50+pub fn dump(value: &Value) -> Vec<u8> {
51+ let mut writer = Writer { out: vec![4, 8], symbols: HashMap::new() };
52+ writer.value(value);
53+ writer.out
54+}
55+
56+struct Writer {
57+ out: Vec<u8>,
58+ symbols: HashMap<String, usize>,
59+}
60+
61+impl Writer {
62+ /// Marshal's integer: 0 as itself, -123..=122 in one byte offset by
63+ /// five, else a byte count (negated for a negative) and the bytes,
64+ /// least first.
65+ fn long(&mut self, n: i64) {
66+ if n == 0 {
67+ self.out.push(0);
68+ } else if (1..123).contains(&n) {
69+ self.out.push((n + 5) as u8);
70+ } else if (-123..0).contains(&n) {
71+ self.out.push(((n - 5) & 0xff) as u8);
72+ } else {
73+ let mut bytes = Vec::new();
74+ let mut rest = n;
75+ for _ in 0..4 {
76+ bytes.push((rest & 0xff) as u8);
77+ rest >>= 8;
78+ if (n > 0 && rest == 0) || (n < 0 && rest == -1) {
79+ break;
80+ }
81+ }
82+ let count = bytes.len() as i64;
83+ self.out.push(if n > 0 { count as u8 } else { (-count & 0xff) as u8 });
84+ self.out.extend_from_slice(&bytes);
85+ }
86+ }
87+
88+ fn bytes(&mut self, bytes: &[u8]) {
89+ self.long(bytes.len() as i64);
90+ self.out.extend_from_slice(bytes);
91+ }
92+
93+ fn symbol(&mut self, name: &str) {
94+ if let Some(&index) = self.symbols.get(name) {
95+ self.out.push(b';');
96+ self.long(index as i64);
97+ return;
98+ }
99+ let index = self.symbols.len();
100+ self.symbols.insert(name.to_owned(), index);
101+ self.out.push(b':');
102+ self.bytes(name.as_bytes());
103+ }
104+
105+ fn value(&mut self, value: &Value) {
106+ match value {
107+ Value::Nil => self.out.push(b'0'),
108+ Value::Bool(true) => self.out.push(b'T'),
109+ Value::Bool(false) => self.out.push(b'F'),
110+ Value::Int(n) => {
111+ self.out.push(b'i');
112+ self.long(i64::from(*n));
113+ }
114+ // A string with an encoding is a string with one instance
115+ // variable, `E`: true for UTF-8.
116+ Value::Str(text) => {
117+ self.out.push(b'I');
118+ self.out.push(b'"');
119+ self.bytes(text.as_bytes());
120+ self.long(1);
121+ self.symbol("E");
122+ self.out.push(b'T');
123+ }
124+ Value::Bytes(bytes) => {
125+ self.out.push(b'"');
126+ self.bytes(bytes);
127+ }
128+ Value::Symbol(name) => self.symbol(name),
129+ Value::Array(items) => {
130+ self.out.push(b'[');
131+ self.long(items.len() as i64);
132+ for item in items {
133+ self.value(item);
134+ }
135+ }
136+ Value::Hash(pairs) => {
137+ self.out.push(b'{');
138+ self.long(pairs.len() as i64);
139+ for (key, item) in pairs {
140+ self.value(key);
141+ self.value(item);
142+ }
143+ }
144+ Value::Object { class, ivars } => {
145+ self.out.push(b'o');
146+ self.symbol(class);
147+ self.long(ivars.len() as i64);
148+ for (name, item) in ivars {
149+ self.symbol(name);
150+ self.value(item);
151+ }
152+ }
153+ Value::UserMarshal { class, data } => {
154+ self.out.push(b'U');
155+ self.symbol(class);
156+ self.value(data);
157+ }
158+ Value::UserDef { class, data } => {
159+ self.out.push(b'u');
160+ self.symbol(class);
161+ self.bytes(data);
162+ }
163+ }
164+ }
165+}
166+
167+#[cfg(test)]
168+mod tests {
169+ use super::*;
170+
171+ #[test]
172+ fn values_are_written_as_ruby_writes_them() {
173+ // Each as `Marshal.dump` writes it in Ruby 3.3.
174+ assert_eq!(dump(&Value::Nil), b"\x04\x080");
175+ assert_eq!(dump(&Value::Bool(true)), b"\x04\x08T");
176+ assert_eq!(dump(&Value::Int(0)), b"\x04\x08i\x00");
177+ assert_eq!(dump(&Value::Int(4)), b"\x04\x08i\x09");
178+ assert_eq!(dump(&Value::Int(-1)), b"\x04\x08i\xfa");
179+ assert_eq!(dump(&Value::Int(123)), b"\x04\x08i\x01\x7b");
180+ assert_eq!(dump(&Value::Int(256)), b"\x04\x08i\x02\x00\x01");
181+ assert_eq!(dump(&Value::Int(-124)), b"\x04\x08i\xff\x84");
182+ assert_eq!(dump(&Value::Int(-256)), b"\x04\x08i\xff\x00");
183+ assert_eq!(dump(&Value::Int(-257)), b"\x04\x08i\xfe\xff\xfe");
184+ assert_eq!(dump(&Value::str("hi")), b"\x04\x08I\"\x07hi\x06:\x06ET");
185+ assert_eq!(dump(&Value::Bytes(b"hi".to_vec())), b"\x04\x08\"\x07hi");
186+ // The second `:a` is a link to the first.
187+ assert_eq!(
188+ dump(&Value::Array(vec![Value::Symbol("a".into()), Value::Symbol("a".into())])),
189+ b"\x04\x08[\x07:\x06a;\x00"
190+ );
191+ assert_eq!(dump(&Value::Hash(vec![(Value::Int(1), Value::Nil)])), b"\x04\x08{\x06i\x060");
192+ // Gem::Version.new("1.0")
193+ assert_eq!(
194+ dump(&Value::UserMarshal { class: "Gem::Version".into(), data: Box::new(Value::Array(vec![Value::str("1.0")])) }),
195+ b"\x04\x08U:\x11Gem::Version[\x06I\"\x081.0\x06:\x06ET"
196+ );
197+ assert_eq!(
198+ dump(&Value::Object { class: "Point".into(), ivars: vec![("@x".into(), Value::Int(1))] }),
199+ b"\x04\x08o:\x0aPoint\x06:\x07@xi\x06"
200+ );
201+ assert_eq!(dump(&Value::UserDef { class: "X".into(), data: b"ab".to_vec() }), b"\x04\x08u:\x06X\x07ab");
202+ }
203+}
+133−0
114114 ArtifactMetadata { group: String, artifact: String, checksum: Option<Checksum> },
115115 /// `com/acme/web/1.0-SNAPSHOT/maven-metadata.xml`: a SNAPSHOT's builds.
116116 VersionMetadata { group: String, artifact: String, version: String, checksum: Option<Checksum> },
117+ /// `acme/maven-metadata.xml`: a one-part group's plugins, by prefix.
118+ /// A deeper group's (`com/acme/plugins/maven-metadata.xml`) reads as an
119+ /// artifact's, and is answered with the plugins of the group the whole
120+ /// path names when there is no such artifact.
121+ GroupMetadata { group: String, checksum: Option<Checksum> },
117122 /// `com/acme/web/1.0.0/web-1.0.0.jar`: one of a version's files.
118123 File { group: String, artifact: String, version: String, file: String, checksum: Option<Checksum> },
119124 }
138143 let (artifact, version) = (parts[n - 3].to_owned(), parts[n - 2].to_owned());
139144 return Some((workspace, MavenPath::VersionMetadata { group, artifact, version, checksum }));
140145 }
146+ if n == 2 && valid_group_part(parts[0]) {
147+ return Some((workspace, MavenPath::GroupMetadata { group: parts[0].to_owned(), checksum }));
148+ }
141149 if n < 3 || !valid_artifact(parts[n - 2]) {
142150 return None;
143151 }
405413 pub description: Option<String>,
406414 /// `<scm><url>`, else `<url>`: where its source is.
407415 pub source: Option<String>,
416+ /// `jar` when it names none; `maven-plugin` for a plugin.
417+ pub packaging: String,
408418 }
409419
410420 /// Reads a POM, with the groupId and version a `<parent>` gives it.
423433 name: project.child_text("name"),
424434 description: project.child_text("description"),
425435 source: project.child("scm").and_then(|scm| scm.child_text("url")).or_else(|| project.child_text("url")),
436+ packaging: project.child_text("packaging").unwrap_or_else(|| "jar".to_owned()),
426437 })
427438 }
428439
440+/// The prefix Maven gives a plugin that names none: its artifactId without
441+/// `maven` and `plugin` (`acme-maven-plugin` and `maven-acme-plugin` are
442+/// `acme`), as `mvn acme:<goal>` calls it.
443+pub fn default_prefix(artifact: &str) -> String {
444+ if artifact == "maven-plugin-plugin" {
445+ return "plugin".to_owned();
446+ }
447+ let strip = |text: &str, word: &str| -> String {
448+ // `-?word-?`, as Maven's regular expression removes it.
449+ let mut out = text.to_owned();
450+ while let Some(at) = out.find(word) {
451+ let start = if at > 0 && out.as_bytes()[at - 1] == b'-' { at - 1 } else { at };
452+ let mut end = at + word.len();
453+ if out.as_bytes().get(end) == Some(&b'-') {
454+ end += 1;
455+ }
456+ out.replace_range(start..end, "");
457+ }
458+ out
459+ };
460+ strip(&strip(artifact, "maven"), "plugin")
461+}
462+
463+/// The plugin descriptor's prefix and name, from the
464+/// `META-INF/maven/plugin.xml` that `maven-plugin-plugin` puts in the jar.
465+pub fn plugin_descriptor(text: &str) -> Option<(Option<String>, Option<String>)> {
466+ let plugin = xml::parse(text).ok()?;
467+ (plugin.name == "plugin").then(|| (plugin.child_text("goalPrefix"), plugin.child_text("name")))
468+}
469+
470+/// One plugin as a group's `maven-metadata.xml` lists it.
471+#[derive(Clone, Debug, PartialEq, Eq)]
472+pub struct Plugin {
473+ pub prefix: String,
474+ pub artifact: String,
475+ pub name: String,
476+}
477+
478+/// The `<plugins>` of a group's `maven-metadata.xml`, by prefix, which is
479+/// how Maven finds `mvn <prefix>:<goal>` among the groups it is told of.
480+pub fn plugins_block(plugins: &[Plugin]) -> String {
481+ let mut sorted: Vec<&Plugin> = plugins.iter().collect();
482+ sorted.sort_by(|a, b| (&a.prefix, &a.artifact).cmp(&(&b.prefix, &b.artifact)));
483+ let mut xml = String::from(" <plugins>
484+");
485+ for plugin in sorted {
486+ xml.push_str(&format!(
487+ " <plugin>
488+ <name>{}</name>
489+ <prefix>{}</prefix>
490+ <artifactId>{}</artifactId>
491+ </plugin>
492+",
493+ xml::escape(&plugin.name),
494+ xml::escape(&plugin.prefix),
495+ xml::escape(&plugin.artifact)
496+ ));
497+ }
498+ xml.push_str(" </plugins>
499+");
500+ xml
501+}
502+
503+/// A group's `maven-metadata.xml`: its plugins alone, or added to an
504+/// artifact's metadata when the path is both.
505+pub fn group_metadata(artifact_xml: Option<String>, plugins: &[Plugin]) -> String {
506+ let block = plugins_block(plugins);
507+ match artifact_xml {
508+ Some(xml) => match xml.rfind("</metadata>") {
509+ Some(at) => format!("{}{block}{}", &xml[..at], &xml[at..]),
510+ None => xml,
511+ },
512+ None => format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>
513+<metadata>
514+{block}</metadata>
515+"),
516+ }
517+}
518+
429519 #[cfg(test)]
430520 mod tests {
431521 use super::*;
474564 assert_eq!(route("/-/maven/acme/com/../web/1.0.0/web-1.0.0.jar"), None);
475565 assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/"), None);
476566 assert_eq!(route("/-/maven/acme/maven-metadata.xml"), None);
567+ assert_eq!(
568+ route("/-/maven/acme/acme/maven-metadata.xml.sha1"),
569+ Some(("acme".into(), MavenPath::GroupMetadata { group: "acme".into(), checksum: Some(Checksum::Sha1) }))
570+ );
477571 assert_eq!(route("/-/maven/"), None);
478572 }
479573
574668 assert_eq!((pom.group.as_str(), pom.artifact.as_str(), pom.version.as_str()), ("com.acme", "web", "1.0.0"));
575669 assert_eq!(pom.description.as_deref(), Some("The web client"));
576670 assert_eq!(pom.source.as_deref(), Some("https://g1t.sh/acme/web"));
671+ assert_eq!(pom.packaging, "jar");
672+ let plugin = read_pom(b"<project><groupId>com.acme</groupId><artifactId>acme-maven-plugin</artifactId><version>1</version><packaging>maven-plugin</packaging></project>").unwrap();
673+ assert_eq!(plugin.packaging, "maven-plugin");
577674 assert!(read_pom(b"<project><artifactId>x</artifactId></project>").is_err(), "no groupId");
578675 assert!(read_pom(b"not xml").is_err());
579676 }
580677
581678 #[test]
679+ fn plugins_are_listed_by_prefix() {
680+ assert_eq!(default_prefix("acme-maven-plugin"), "acme");
681+ assert_eq!(default_prefix("maven-acme-plugin"), "acme");
682+ assert_eq!(default_prefix("hello-plugin"), "hello");
683+ assert_eq!(default_prefix("maven-plugin-plugin"), "plugin");
684+ assert_eq!(default_prefix("tools"), "tools");
685+ assert_eq!(
686+ plugin_descriptor("<plugin><name>Acme</name><groupId>com.acme</groupId><goalPrefix>acme</goalPrefix><mojos/></plugin>"),
687+ Some((Some("acme".to_owned()), Some("Acme".to_owned())))
688+ );
689+ assert_eq!(plugin_descriptor("<project/>"), None);
690+ let plugins = [
691+ Plugin { prefix: "zed".into(), artifact: "zed-maven-plugin".into(), name: "Zed".into() },
692+ Plugin { prefix: "acme".into(), artifact: "acme-maven-plugin".into(), name: "Acme & co".into() },
693+ ];
694+ let doc = xml::parse(&group_metadata(None, &plugins)).unwrap();
695+ let listed: Vec<(String, String, String)> = doc
696+ .child("plugins")
697+ .unwrap()
698+ .children_named("plugin")
699+ .map(|p| (p.child_text("prefix").unwrap(), p.child_text("artifactId").unwrap(), p.child_text("name").unwrap()))
700+ .collect();
701+ assert_eq!(
702+ listed,
703+ [
704+ ("acme".to_owned(), "acme-maven-plugin".to_owned(), "Acme & co".to_owned()),
705+ ("zed".to_owned(), "zed-maven-plugin".to_owned(), "Zed".to_owned())
706+ ]
707+ );
708+ // A path that is an artifact and a group says both.
709+ let both = group_metadata(Some(artifact_metadata("com", "acme", &["1.0".into()], "2026-10-06T00:00:00Z")), &plugins[..1]);
710+ let doc = xml::parse(&both).unwrap();
711+ assert!(doc.child("versioning").is_some() && doc.child("plugins").is_some());
712+ }
713+
714+ #[test]
582715 fn checksums_are_hex_of_the_file() {
583716 assert_eq!(split_checksum("web-1.0.jar.sha1"), ("web-1.0.jar", Some(Checksum::Sha1)));
584717 assert_eq!(split_checksum("web-1.0.jar"), ("web-1.0.jar", None));
+88−5
2525 use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result};
2626
2727 use crate::access::{self, Action};
28+use crate::archive;
2829 use crate::db::{Checksums, NewFile, NewVersion, PackageRow, VersionRow};
2930 use crate::digest::Digest;
3031 use crate::maven::{self, Checksum, MavenPath};
3839 const MAX_VERSIONS: u32 = 5000;
3940 /// The longest POM read for its description and source.
4041 const MAX_POM_BYTES: usize = 1024 * 1024;
42+/// The most artifacts of a group read for its plugins.
43+const MAX_GROUP: u32 = 500;
44+/// Where a plugin's jar keeps its descriptor, and the most read of it.
45+const PLUGIN_DESCRIPTOR: &str = "META-INF/maven/plugin.xml";
46+const MAX_DESCRIPTOR_BYTES: usize = 4 * 1024 * 1024;
4147 const DOCS: &str = "https://docs.g1t.sh/guides/maven/";
4248 const TOKENS: &str = "https://g1t.sh/settings/tokens";
4349
106112 (MavenPath::ArtifactMetadata { group, artifact, checksum }, Method::Get | Method::Head) => {
107113 self.maven_metadata(workspace, &group, &artifact, None, checksum, viewer, head).await
108114 }
115+ (MavenPath::GroupMetadata { group, checksum }, Method::Get | Method::Head) => {
116+ self.maven_group_metadata(workspace, &group, None, checksum, viewer, head).await
117+ }
109118 (MavenPath::VersionMetadata { group, artifact, version, checksum }, Method::Get | Method::Head) => {
110119 self.maven_metadata(workspace, &group, &artifact, Some(&version), checksum, viewer, head).await
111120 }
120129 let name = maven::package_name(&group, &artifact);
121130 self.maven_checksum(&mut request, workspace, &name, &version, &file, checksum, viewer).await
122131 }
123− (path @ (MavenPath::ArtifactMetadata { .. } | MavenPath::VersionMetadata { .. }), Method::Put) => {
132+ (path @ (MavenPath::ArtifactMetadata { .. } | MavenPath::VersionMetadata { .. } | MavenPath::GroupMetadata { .. }), Method::Put) => {
124133 self.maven_metadata_upload(&mut request, workspace, &path, viewer).await
125134 }
126135 _ => error(405, "Not a method this address takes. Versions are deleted on the package's page."),
172181 viewer: Option<&User>,
173182 head: bool,
174183 ) -> Result<Response> {
175− let Some(package) = self.maven_package(workspace, &maven::package_name(group, artifact)).await? else {
184+ let found = self.maven_package(workspace, &maven::package_name(group, artifact)).await?;
185+ // `com/acme/plugins/maven-metadata.xml` is also the group
186+ // `com.acme.plugins`'s, which lists its plugins.
187+ let Some(package) = found else {
188+ if snapshot.is_none() {
189+ return self.maven_group_metadata(workspace, &format!("{group}.{artifact}"), None, checksum, viewer, head).await;
190+ }
176191 return self.maven_absent(workspace, viewer).await;
177192 };
178193 if let Some(refusal) = self.maven_check(viewer, &package, Action::Pull).await? {
184199 if versions.is_empty() {
185200 return self.maven_absent(workspace, viewer).await;
186201 }
187− maven::artifact_metadata(group, artifact, &versions, &package.updated_at)
202+ let xml = maven::artifact_metadata(group, artifact, &versions, &package.updated_at);
203+ return self.maven_group_metadata(workspace, &format!("{group}.{artifact}"), Some(xml), checksum, viewer, head).await;
188204 }
189205 Some(version) => {
190206 let Some(row) = self.db.version_named(&package.id, version).await? else {
203219 }
204220 }
205221
222+ /// A group's `maven-metadata.xml`: the plugins among its artifacts the
223+ /// viewer may see, by prefix, so `mvn <prefix>:<goal>` finds them when
224+ /// the group is one of its `<pluginGroups>`. `artifact` is the
225+ /// metadata of an artifact at the same path, which it is added to.
226+ async fn maven_group_metadata(
227+ &self,
228+ workspace: &str,
229+ group: &str,
230+ artifact: Option<String>,
231+ checksum: Option<Checksum>,
232+ viewer: Option<&User>,
233+ head: bool,
234+ ) -> Result<Response> {
235+ let mut plugins = Vec::new();
236+ for package in self.db.maven_group(workspace, group, MAX_GROUP).await? {
237+ if !access::decide(viewer, &TargetOf::package(&package).view(), Action::Pull).allowed {
238+ continue;
239+ }
240+ let artifact_id = package.name.rsplit(':').next().unwrap_or("").to_owned();
241+ // The highest version that is a plugin says its prefix and name.
242+ let mut versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
243+ versions.sort_by(|a, b| maven::compare(&b.version, &a.version));
244+ let Some(meta) = versions.iter().map(VersionRow::meta).find(|m| m["packaging"] == "maven-plugin" || m["plugin"].is_object()) else {
245+ continue;
246+ };
247+ let text = |value: &Value| value.as_str().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned);
248+ plugins.push(maven::Plugin {
249+ prefix: text(&meta["plugin"]["prefix"]).unwrap_or_else(|| maven::default_prefix(&artifact_id)),
250+ name: text(&meta["name"]).or_else(|| text(&meta["plugin"]["name"])).unwrap_or_else(|| artifact_id.clone()),
251+ artifact: artifact_id,
252+ });
253+ }
254+ let xml = match (artifact, plugins.is_empty()) {
255+ (artifact, false) => maven::group_metadata(artifact, &plugins),
256+ (Some(xml), true) => xml,
257+ (None, true) => return self.maven_absent(workspace, viewer).await,
258+ };
259+ match checksum {
260+ Some(checksum) => serve(checksum.of(xml.as_bytes()).into_bytes(), "text/plain", head, "no-cache"),
261+ None => serve(xml.into_bytes(), "application/xml", head, "no-cache"),
262+ }
263+ }
264+
206265 /// One of a version's files, or a checksum of it.
207266 #[allow(clippy::too_many_arguments)]
208267 async fn maven_file(
360419 None
361420 };
362421
422+ // The main jar of a Maven plugin holds its descriptor.
423+ let plugin = if parsed.classifier.is_none() && parsed.extension == "jar" {
424+ archive::zip_entries(&bytes)
425+ .ok()
426+ .and_then(|entries| entries.into_iter().find(|e| e.name == PLUGIN_DESCRIPTOR))
427+ .and_then(|entry| archive::zip_read(&bytes, &entry, MAX_DESCRIPTOR_BYTES).ok())
428+ .and_then(|xml| maven::plugin_descriptor(&String::from_utf8_lossy(&xml)))
429+ } else {
430+ None
431+ };
432+
363433 let name = maven::package_name(group, artifact);
364434 let found = self.db.package(workspace, MAVEN, &name).await?;
365435 if found.as_ref().is_some_and(PackageRow::hidden) || (found.is_none() && self.db.workspace_hidden(workspace).await?) {
445515
446516 if let Some(pom) = pom {
447517 self.maven_pom(&package, &row, &digest, &pom, viewer).await?;
518+ } else if let Some((prefix, title)) = plugin {
519+ // A plugin's jar names the prefix it is called by.
520+ let mut metadata = self.db.version_named(&package.id, version).await?.map(|v| v.meta()).unwrap_or_default();
521+ if !metadata.is_object() {
522+ metadata = json!({});
523+ }
524+ metadata["plugin"] = json!({ "prefix": prefix, "name": title });
525+ self.db.set_version(&row.id, &row.digest, &metadata.to_string()).await?;
448526 }
449527 created()
450528 }
463541 metadata["name"] = json!(pom.name);
464542 metadata["description"] = json!(pom.description);
465543 metadata["source"] = json!(pom.source);
544+ metadata["packaging"] = json!(pom.packaging);
466545 self.db.set_version(&row.id, &digest.to_string(), &metadata.to_string()).await?;
467546 let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
468547 let releases: Vec<&str> = versions.iter().map(|v| v.version.as_str()).filter(|v| !maven::is_snapshot(v)).collect();
579658 if let Err(refused) = self.maven_body(request).await? {
580659 return Ok(refused);
581660 }
582− let (MavenPath::ArtifactMetadata { group, artifact, .. } | MavenPath::VersionMetadata { group, artifact, .. } | MavenPath::File { group, artifact, .. }) = path;
583− let found = self.maven_package(workspace, &maven::package_name(group, artifact)).await?;
661+ let found = match path {
662+ MavenPath::ArtifactMetadata { group, artifact, .. } | MavenPath::VersionMetadata { group, artifact, .. } | MavenPath::File { group, artifact, .. } => {
663+ self.maven_package(workspace, &maven::package_name(group, artifact)).await?
664+ }
665+ MavenPath::GroupMetadata { .. } => None,
666+ };
584667 let target = match &found {
585668 Some(package) => TargetOf::package(package),
586669 // A plugin group's metadata names no artifact of its own.
+205−4
11 //! What the NuGet feed needs that does not touch the network: package ids
22 //! and NuGet's normalized versions, the feed's paths, the `.nuspec` read
3−//! from a `.nupkg` (a zip), the multipart body `dotnet nuget push` sends,
4−//! and the service index, registration and search documents of the v3
5−//! protocol.
3+//! from a `.nupkg` (a zip), the portable PDBs read from a `.snupkg` and
4+//! the keys the symbol server finds them by, the multipart body `dotnet
5+//! nuget push` sends, and the service index, registration and search
6+//! documents of the v3 protocol.
67 //!
78 //! A version keeps what the documents need from its `.nuspec` as its
89 //! metadata, made once when it is pushed. Unlisting (`dotnet nuget
121122 pub enum Content {
122123 Nupkg,
123124 Nuspec,
125+ /// The symbol package, when one was pushed.
126+ Snupkg,
124127 }
125128
129+impl Content {
130+ /// The name the version keeps the file by.
131+ pub fn file(self) -> &'static str {
132+ match self {
133+ Content::Nupkg => "nupkg",
134+ Content::Nuspec => "nuspec",
135+ Content::Snupkg => "snupkg",
136+ }
137+ }
138+}
139+
126140 /// One of the feed's endpoints, under `/-/nuget/<workspace>/`.
127141 #[derive(Clone, Debug, PartialEq, Eq)]
128142 pub enum NugetRoute {
142156 Push,
143157 /// `api/v2/package/<id>/<version>`: `DELETE` unlists, `POST` lists again.
144158 Listing { id: String, version: String },
159+ /// `api/v2/symbolpackage`: `dotnet nuget push` of a `.snupkg`.
160+ SymbolPush,
161+ /// `symbols/<file>.pdb/<key>/<file>.pdb`: a PDB from the symbol server,
162+ /// by the key a debugger asks with; both lowercased.
163+ Symbol { file: String, key: String },
145164 }
146165
147166 /// The workspace and endpoint a path is. Ids are checked; versions are
163182 let lower = format!("{}.{}", name.to_ascii_lowercase(), version.to_ascii_lowercase());
164183 let file = if file.eq_ignore_ascii_case(&format!("{lower}.nupkg")) {
165184 Content::Nupkg
185+ } else if file.eq_ignore_ascii_case(&format!("{lower}.snupkg")) {
186+ Content::Snupkg
166187 } else if file.eq_ignore_ascii_case(&format!("{name}.nuspec")) {
167188 Content::Nuspec
168189 } else {
174195 ["v3", "registration", name, leaf] => NugetRoute::Leaf { id: id(name)?, version: leaf.strip_suffix(".json")?.to_owned() },
175196 ["api", "v2", "package"] => NugetRoute::Push,
176197 ["api", "v2", "package", name, version] => NugetRoute::Listing { id: id(name)?, version: (*version).to_owned() },
198+ ["api", "v2", "symbolpackage"] => NugetRoute::SymbolPush,
199+ ["symbols", file, key, again] if file.eq_ignore_ascii_case(again) && valid_pdb_name(file) && valid_key(key) => {
200+ NugetRoute::Symbol { file: file.to_ascii_lowercase(), key: key.to_ascii_lowercase() }
201+ }
177202 _ => return None,
178203 };
179204 Some((workspace, route))
180205 }
181206
207+/// A PDB's file name, as a symbol server path holds it: no folders.
208+fn valid_pdb_name(file: &str) -> bool {
209+ file.len() <= 255
210+ && file.to_ascii_lowercase().ends_with(".pdb")
211+ && file.len() > 4
212+ && file.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'+'))
213+}
214+
215+/// A symbol server key: hex, as `<guid><age>` is.
216+fn valid_key(key: &str) -> bool {
217+ (1..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_hexdigit())
218+}
219+
220+/// The 20-byte id of a portable PDB (`#Pdb` stream's first bytes: a GUID
221+/// and a stamp), which the assembly built with it names too. `None` for a
222+/// file that is not a portable PDB (a Windows PDB, say).
223+pub fn pdb_id(bytes: &[u8]) -> Option<[u8; 20]> {
224+ let u16_at = |at: usize| Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?));
225+ let u32_at = |at: usize| Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?));
226+ // ECMA-335 II.24.2.1: the metadata root, its version string, then
227+ // each stream's offset, size and name, padded to four bytes.
228+ if u32_at(0)? != 0x424A_5342 {
229+ return None;
230+ }
231+ let length = u32_at(12)? as usize;
232+ let mut at = 16usize.checked_add(length)?;
233+ let streams = u16_at(at + 2)?;
234+ at += 4;
235+ for _ in 0..streams {
236+ let (offset, size) = (u32_at(at)? as usize, u32_at(at + 4)? as usize);
237+ let name_start = at + 8;
238+ let name_len = bytes.get(name_start..)?.iter().take(32).position(|b| *b == 0)?;
239+ let name = &bytes[name_start..name_start + name_len];
240+ at = name_start + (name_len + 1).div_ceil(4) * 4;
241+ if name == b"#Pdb" && size >= 20 {
242+ return bytes.get(offset..offset + 20)?.try_into().ok();
243+ }
244+ }
245+ None
246+}
247+
248+/// The key a symbol server finds a portable PDB by: its GUID as .NET
249+/// writes it (`Guid.ToString("N")`: the first three fields byte-swapped)
250+/// and `ffffffff` for its age, lowercased.
251+pub fn symbol_key(id: &[u8; 20]) -> String {
252+ let mut guid = Vec::with_capacity(16);
253+ guid.extend(id[..4].iter().rev());
254+ guid.extend(id[4..6].iter().rev());
255+ guid.extend(id[6..8].iter().rev());
256+ guid.extend(&id[8..16]);
257+ format!("{}ffffffff", hex::encode(guid))
258+}
259+
260+/// The name a version keeps a PDB by: `pdb:<file>:<key>`, lowercased, as
261+/// the symbol server looks it up.
262+pub fn symbol_file(file: &str, key: &str) -> String {
263+ format!("pdb:{}:{}", file.to_ascii_lowercase(), key.to_ascii_lowercase())
264+}
265+
266+/// One PDB from a symbol package: its file name and key, and its bytes.
267+#[derive(Debug)]
268+pub struct Pdb {
269+ pub file: String,
270+ pub key: String,
271+ pub bytes: Vec<u8>,
272+}
273+
274+/// What a `.snupkg` holds: its `.nuspec`, which names the package and
275+/// version it is for, and its portable PDBs.
276+#[derive(Debug)]
277+pub struct Symbols {
278+ pub nuspec: Nuspec,
279+ pub pdbs: Vec<Pdb>,
280+}
281+
282+/// The largest PDB read from a symbol package.
283+const MAX_PDB_BYTES: usize = 64 * 1024 * 1024;
284+
285+/// Reads a `.snupkg`: a zip with a `.nuspec` of the `SymbolsPackage`
286+/// type, and one or more portable PDBs.
287+pub fn read_symbols(snupkg: &[u8]) -> Result<Symbols, String> {
288+ let package = read_package(snupkg)?;
289+ if !package.nuspec.package_types.iter().any(|t| t.eq_ignore_ascii_case("SymbolsPackage")) {
290+ return Err("The .nuspec does not say it is a symbol package (<packageType name=\"SymbolsPackage\" />). Build it with SymbolPackageFormat snupkg.".to_owned());
291+ }
292+ let entries = archive::zip_entries(snupkg)?;
293+ let mut pdbs = Vec::new();
294+ for entry in entries.iter().filter(|e| e.name.to_ascii_lowercase().ends_with(".pdb")) {
295+ let file = entry.name.rsplit(['/', '\\']).next().unwrap_or(&entry.name).to_owned();
296+ let bytes = archive::zip_read(snupkg, entry, MAX_PDB_BYTES)?;
297+ let Some(id) = pdb_id(&bytes) else {
298+ return Err(format!("{} is not a portable PDB. Build with DebugType portable (the default).", entry.name));
299+ };
300+ pdbs.push(Pdb { file, key: symbol_key(&id), bytes });
301+ }
302+ if pdbs.is_empty() {
303+ return Err("The symbol package holds no .pdb files.".to_owned());
304+ }
305+ Ok(Symbols { nuspec: package.nuspec, pdbs })
306+}
307+
182308 /// The `.nupkg` file in a `multipart/form-data` body, as `dotnet nuget
183309 /// push` sends it; a body that is not multipart is taken as the file.
184310 pub fn pushed_file<'a>(content_type: Option<&str>, body: &'a [u8]) -> Result<&'a [u8], String> {
228354 pub readme: Option<String>,
229355 pub require_license_acceptance: bool,
230356 pub groups: Vec<Group>,
357+ /// `<packageTypes>`: `SymbolsPackage` for a `.snupkg`.
358+ pub package_types: Vec<String>,
231359 }
232360
233361 /// A dependency's `version` as a range: `1.0` (at least 1.0) is
277405 readme: metadata.child_text("readme"),
278406 require_license_acceptance: metadata.child_text("requireLicenseAcceptance").is_some_and(|v| v.eq_ignore_ascii_case("true")),
279407 groups,
408+ package_types: metadata
409+ .child("packageTypes")
410+ .map(|types| types.children_named("packageType").filter_map(|t| t.attribute("name")).map(str::to_owned).collect())
411+ .unwrap_or_default(),
280412 })
281413 }
282414
353485 resource(query.clone(), "SearchQueryService/3.0.0-beta"),
354486 resource(query, "SearchQueryService/3.5.0"),
355487 resource(format!("{base}/api/v2/package"), "PackagePublish/2.0.0"),
488+ resource(format!("{base}/api/v2/symbolpackage"), "SymbolPackagePublish/4.9.0"),
356489 ],
357490 })
358491 }
429562 "listed": listed.listed,
430563 "published": listed.published,
431564 "packageContent": at.content,
565+ "downloads": listed.downloads,
432566 },
433567 "packageContent": at.content,
434568 "registration": at.registration,
536670 assert_eq!(route("/-/nuget/acme/api/v2/package/"), at(NugetRoute::Push));
537671 assert_eq!(route("/-/nuget/acme/api/v2/package/Acme.Web/1.0.0"), at(NugetRoute::Listing { id: "Acme.Web".into(), version: "1.0.0".into() }));
538672 assert_eq!(route("/-/nuget/acme/v3/flatcontainer/a..b/index.json"), None);
673+ assert_eq!(
674+ route("/-/nuget/acme/v3/flatcontainer/acme.web/1.0.0/acme.web.1.0.0.snupkg"),
675+ at(NugetRoute::Content { id: "acme.web".into(), version: "1.0.0".into(), file: Content::Snupkg })
676+ );
677+ assert_eq!(route("/-/nuget/acme/api/v2/symbolpackage"), at(NugetRoute::SymbolPush));
678+ assert_eq!(
679+ route("/-/nuget/acme/symbols/Acme.Web.pdb/0A1B2C3D4E5F60718293A4B5C6D7E8F9ffffffff/acme.web.pdb"),
680+ at(NugetRoute::Symbol { file: "acme.web.pdb".into(), key: "0a1b2c3d4e5f60718293a4b5c6d7e8f9ffffffff".into() })
681+ );
682+ assert_eq!(route("/-/nuget/acme/symbols/a.pdb/xyz/a.pdb"), None, "not hex");
683+ assert_eq!(route("/-/nuget/acme/symbols/a.pdb/00/b.pdb"), None, "two names");
684+ assert_eq!(route("/-/nuget/acme/symbols/a.dll/00/a.dll"), None, "only PDBs");
539685 assert_eq!(route("/-/nuget/acme"), None);
540686 assert_eq!(route("/-/nuget/acme/v2"), None);
541687 }
599745 fn the_documents_are_nugets_shape() {
600746 let index = service_index("https://g1t.sh/-/nuget/acme");
601747 let kinds: Vec<&str> = index["resources"].as_array().unwrap().iter().map(|r| r["@type"].as_str().unwrap()).collect();
602− for kind in ["PackageBaseAddress/3.0.0", "RegistrationsBaseUrl", "SearchQueryService", "PackagePublish/2.0.0"] {
748+ for kind in ["PackageBaseAddress/3.0.0", "RegistrationsBaseUrl", "SearchQueryService", "PackagePublish/2.0.0", "SymbolPackagePublish/4.9.0"] {
603749 assert!(kinds.contains(&kind), "{kind}");
604750 }
605751 let spec = read_nuspec(NUSPEC).unwrap();
620766 assert_eq!(entry["dependencyGroups"][0]["targetFramework"], "net8.0");
621767 assert_eq!(entry["dependencyGroups"][0]["dependencies"][1]["range"], "[1.0.0, 2.0.0)");
622768 assert_eq!(page["items"][0]["catalogEntry"]["listed"], false);
769+ assert_eq!(page["items"][0]["catalogEntry"]["downloads"], 3, "each version's own");
623770 let found = search_result(base, "Acme.Web", &versions).unwrap();
624771 assert_eq!(found["version"], "1.2.0");
625772 assert_eq!(found["versions"].as_array().unwrap().len(), 1, "unlisted versions are not searched");
627774 assert_eq!(found["authors"], json!(["Ada", "Bo"]));
628775 assert!(search_result(base, "Acme.Web", &versions[..1]).is_none());
629776 }
777+
778+ /// A portable PDB's start: the metadata root, a version string, and
779+ /// two streams, `#Pdb` holding the id.
780+ fn portable_pdb(id: &[u8; 20]) -> Vec<u8> {
781+ let version = b"PDB v1.0\0\0\0\0";
782+ let mut pdb = Vec::new();
783+ pdb.extend_from_slice(&0x424A_5342u32.to_le_bytes());
784+ pdb.extend_from_slice(&[1, 0, 1, 0, 0, 0, 0, 0]);
785+ pdb.extend_from_slice(&(version.len() as u32).to_le_bytes());
786+ pdb.extend_from_slice(version);
787+ pdb.extend_from_slice(&[0, 0, 2, 0]);
788+ // Each stream: offset, size, and its name padded to four bytes.
789+ pdb.extend_from_slice(&84u32.to_le_bytes());
790+ pdb.extend_from_slice(&16u32.to_le_bytes());
791+ pdb.extend_from_slice(b"#GUID\0\0\0");
792+ pdb.extend_from_slice(&64u32.to_le_bytes());
793+ pdb.extend_from_slice(&20u32.to_le_bytes());
794+ pdb.extend_from_slice(b"#Pdb\0\0\0\0");
795+ assert_eq!(pdb.len(), 64);
796+ pdb.extend_from_slice(id);
797+ pdb.extend_from_slice(&[0; 16]);
798+ pdb
799+ }
800+
801+ #[test]
802+ fn a_portable_pdb_is_found_by_its_guid() {
803+ // The GUID 3d2c1b0a-5f4e-7160-8293-a4b5c6d7e8f9, as .NET lays it
804+ // out in bytes, and a stamp.
805+ let mut id = [0u8; 20];
806+ id[..16].copy_from_slice(&[0x0a, 0x1b, 0x2c, 0x3d, 0x4e, 0x5f, 0x60, 0x71, 0x82, 0x93, 0xa4, 0xb5, 0xc6, 0xd7, 0xe8, 0xf9]);
807+ id[16..].copy_from_slice(&[1, 2, 3, 4]);
808+ let pdb = portable_pdb(&id);
809+ assert_eq!(pdb_id(&pdb), Some(id));
810+ assert_eq!(symbol_key(&id), "3d2c1b0a5f4e71608293a4b5c6d7e8f9ffffffff");
811+ assert_eq!(pdb_id(b"Microsoft C/C++ MSF 7.00\r\n"), None, "a Windows PDB");
812+ assert_eq!(symbol_file("Acme.Web.pdb", "ABC"), "pdb:acme.web.pdb:abc");
813+
814+ let nuspec = r#"<package><metadata><id>Acme.Web</id><version>1.0.0</version><packageTypes><packageType name="SymbolsPackage" /></packageTypes></metadata></package>"#;
815+ let snupkg = crate::composer::zip(&[
816+ ("Acme.Web.nuspec".to_owned(), nuspec.as_bytes().to_vec()),
817+ ("lib/net8.0/Acme.Web.pdb".to_owned(), pdb.clone()),
818+ ]);
819+ let symbols = read_symbols(&snupkg).unwrap();
820+ assert_eq!(symbols.nuspec.id, "Acme.Web");
821+ assert_eq!(symbols.pdbs.len(), 1);
822+ assert_eq!((symbols.pdbs[0].file.as_str(), symbols.pdbs[0].key.as_str()), ("Acme.Web.pdb", symbol_key(&id).as_str()));
823+ let plain = crate::composer::zip(&[("Acme.Web.nuspec".to_owned(), NUSPEC.as_bytes().to_vec()), ("lib/a.pdb".to_owned(), pdb)]);
824+ assert!(read_symbols(&plain).is_err(), "not a symbol package");
825+ let windows = crate::composer::zip(&[
826+ ("Acme.Web.nuspec".to_owned(), nuspec.as_bytes().to_vec()),
827+ ("lib/a.pdb".to_owned(), b"Microsoft C/C++ MSF 7.00\r\n".to_vec()),
828+ ]);
829+ assert!(read_symbols(&windows).unwrap_err().contains("portable"));
830+ }
630831 }
+126−10
77 //! A `.nupkg` is stored once, by its SHA-256, with its `.nuspec` beside it;
88 //! the flat container, registration and search documents are made from the
99 //! versions on each read. `dotnet nuget delete` unlists a version, as
10−//! nuget.org does: it is still downloaded by those who name it.
10+//! nuget.org does: it is still downloaded by those who name it. Each
11+//! `.nupkg` download counts for its version as well as its package.
12+//!
13+//! A symbol package (`.snupkg`, pushed to `api/v2/symbolpackage` after its
14+//! `.nupkg`) is kept beside the version, and each portable PDB in it by
15+//! the key debuggers ask the symbol server (`symbols/`) with, as the
16+//! Simple Symbol Query Protocol names it: `<file>/<guid>ffffffff/<file>`.
1117
1218 use g1t_contracts::User;
1319 use g1t_contracts::audit::AuditActor;
130136 NugetRoute::Push if method == Method::Put => self.nuget_push(&mut request, workspace, viewer).await,
131137 NugetRoute::Listing { id, version } if method == Method::Delete => self.nuget_listing(workspace, &id, &version, false, viewer).await,
132138 NugetRoute::Listing { id, version } if method == Method::Post => self.nuget_listing(workspace, &id, &version, true, viewer).await,
139+ NugetRoute::SymbolPush if method == Method::Put => self.nuget_symbol_push(&mut request, workspace, viewer).await,
140+ NugetRoute::Symbol { file, key } if read => self.nuget_symbol(workspace, &file, &key, viewer, head).await,
133141 _ => error(405, "Not a method this address takes."),
134142 }
135143 }
202210 let Some(row) = versions.iter().find(|v| v.version.to_ascii_lowercase() == wanted) else {
203211 return self.nuget_absent(workspace, viewer).await;
204212 };
205− let name = match file {
206− Content::Nupkg => "nupkg",
207− Content::Nuspec => "nuspec",
208− };
209− let Some(kept) = self.db.file(&row.id, name).await? else {
213+ let Some(kept) = self.db.file(&row.id, file.file()).await? else {
210214 return self.nuget_absent(workspace, viewer).await;
211215 };
212216 let Some(digest) = Digest::parse(&kept.digest) else {
216220 return self.nuget_absent(workspace, viewer).await;
217221 };
218222 let headers = Headers::new();
219− headers.set("content-type", if file == Content::Nupkg { "application/octet-stream" } else { "application/xml" })?;
223+ headers.set("content-type", if file == Content::Nuspec { "application/xml" } else { "application/octet-stream" })?;
220224 headers.set("content-length", &blob.size.to_string())?;
221225 headers.set("cache-control", "max-age=31536000")?;
222226 if head {
226230 return self.nuget_absent(workspace, viewer).await;
227231 };
228232 if file == Content::Nupkg {
229− self.count_download(&package.id, ctx);
233+ self.count_version_download(&package.id, &row.id, ctx);
230234 }
231235 Ok(Response::from_body(got.body)?.with_headers(headers))
232236 }
241245 let listed: Vec<Listed<'_>> = versions
242246 .iter()
243247 .zip(&metadata)
244− .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: 0 })
248+ .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: row.downloads })
245249 .collect();
246250 match version {
247251 None => json_response(&nuget::registration(base, &package.name, &listed), head),
287291 let mut listed: Vec<Listed<'_>> = rows
288292 .iter()
289293 .zip(&metadata)
290− .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: 0 })
294+ .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: row.downloads })
291295 .collect();
292296 listed.sort_by(|a, b| nuget::compare(a.version, b.version));
293297 if let Some(mut result) = nuget::search_result(base, &package.name, &listed) {
470474 error(201, format!("{} {version} was pushed.", package.name))
471475 }
472476
477+ /// `dotnet nuget push` of a `.snupkg`, which it sends after the
478+ /// `.nupkg` beside it: the symbols of a version already pushed, kept
479+ /// with it, and each portable PDB in it kept by its symbol server key.
480+ async fn nuget_symbol_push(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
481+ let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
482+ let too_large = || {
483+ let mb = self.max_request / 1_000_000;
484+ error(413, format!("A push may be at most {mb} MB. See {DOCS}#size"))
485+ };
486+ if declared.is_some_and(|n| n > self.max_request) {
487+ return too_large();
488+ }
489+ let content_type = request.headers().get("content-type")?;
490+ let body = request.bytes().await?;
491+ if body.len() as u64 > self.max_request {
492+ return too_large();
493+ }
494+ if viewer.is_none() {
495+ return error(401, format!("Push with a g1t access token as the API key: dotnet nuget push <file> --api-key <token>. Make one at {TOKENS}."));
496+ }
497+ let snupkg = match nuget::pushed_file(content_type.as_deref(), &body) {
498+ Ok(file) => file,
499+ Err(message) => return error(400, message),
500+ };
501+ let symbols = match nuget::read_symbols(snupkg) {
502+ Ok(symbols) => symbols,
503+ Err(message) => return error(400, message),
504+ };
505+ let spec = &symbols.nuspec;
506+ let Some(version) = nuget::normalize(&spec.version) else {
507+ return error(400, format!("{} is not a version NuGet reads.", spec.version));
508+ };
509+ let push_first = || error(404, format!("Push {} {version} before its symbols: dotnet nuget push pushes the .snupkg beside a .nupkg after it.", spec.id));
510+ let Some(package) = self.nuget_package(workspace, &spec.id).await? else {
511+ return push_first();
512+ };
513+ if let Some(refusal) = self.nuget_check(viewer, &package, Action::Push).await? {
514+ return Ok(refusal);
515+ }
516+ let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
517+ let Some(row) = versions.iter().find(|v| v.version.eq_ignore_ascii_case(&version)) else {
518+ return push_first();
519+ };
520+ let snupkg = snupkg.to_vec();
521+ let digest = Digest::of(&snupkg);
522+ if let Some(kept) = self.db.file(&row.id, Content::Snupkg.file()).await? {
523+ if kept.digest == digest.to_string() {
524+ return error(201, format!("The symbols of {} {} were pushed.", package.name, row.version));
525+ }
526+ return error(409, format!("{} {} already has symbols, and a version's symbols are pushed once. Bump the version.", package.name, row.version));
527+ }
528+ let mut files = vec![(Content::Snupkg.file().to_owned(), digest.clone(), snupkg)];
529+ for pdb in symbols.pdbs {
530+ let name = nuget::symbol_file(&pdb.file, &pdb.key);
531+ if files.iter().all(|(kept, _, _)| *kept != name) {
532+ files.push((name, Digest::of(&pdb.bytes), pdb.bytes));
533+ }
534+ }
535+ let sizes: Vec<(String, u64)> = files.iter().map(|(_, d, bytes)| (d.to_string(), bytes.len() as u64)).collect();
536+ if let Some(refusal) = self.storage_refusal(&package, &sizes).await? {
537+ return error(403, refusal);
538+ }
539+ let now = now_ms();
540+ for (name, digest, bytes) in files {
541+ let size = bytes.len() as u64;
542+ let stored = match self.db.blob(&digest).await? {
543+ Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
544+ None => false,
545+ };
546+ if !stored {
547+ self.store.put(&digest.object_key(), bytes).await?;
548+ }
549+ self.db.keep_blob(&package.id, &digest, size, Some("application/octet-stream"), &digest.object_key(), now).await?;
550+ let file = NewFile { name, digest: digest.to_string(), size, media_type: Some("application/octet-stream".to_owned()) };
551+ self.db.put_file(&package.id, &row.id, &file, now).await?;
552+ }
553+ let mut metadata = row.meta();
554+ if metadata.is_object() {
555+ metadata["symbols"] = json!(true);
556+ self.db.set_version(&row.id, &row.digest, &metadata.to_string()).await?;
557+ }
558+ self.db.measure(&package.workspace).await?;
559+ let caller = Caller { actor: viewer.map(AuditActor::of) };
560+ self.audit(&caller, "package.publish_symbols", &package, Some(&format!("{workspace}/{}@{}", package.name, row.version)), None).await;
561+ error(201, format!("The symbols of {} {} were pushed.", package.name, row.version))
562+ }
563+
564+ /// The symbol server: a PDB by its file name and key, from a package
565+ /// of the workspace the viewer may read.
566+ async fn nuget_symbol(&self, workspace: &str, file: &str, key: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
567+ for found in self.db.files_named(workspace, NUGET, &nuget::symbol_file(file, key), 10).await? {
568+ let Some(package) = self.db.package_by_id(&found.package_id).await?.filter(|p| !p.hidden()) else {
569+ continue;
570+ };
571+ if !access::decide(viewer, &TargetOf::package(&package).view(), Action::Pull).allowed {
572+ continue;
573+ }
574+ let Some(digest) = Digest::parse(&found.digest) else { continue };
575+ let Some(blob) = self.db.package_blob(&package.id, &digest).await? else { continue };
576+ let headers = Headers::new();
577+ headers.set("content-type", "application/octet-stream")?;
578+ headers.set("content-length", &blob.size.to_string())?;
579+ headers.set("cache-control", "max-age=31536000")?;
580+ if head {
581+ return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
582+ }
583+ let Some(got) = self.store.get(&blob.object_key, None).await? else { continue };
584+ return Ok(Response::from_body(got.body)?.with_headers(headers));
585+ }
586+ self.nuget_absent(workspace, viewer).await
587+ }
588+
473589 /// `dotnet nuget delete` unlists a version; a `POST` lists it again.
474590 async fn nuget_listing(&self, workspace: &str, id: &str, version: &str, listed: bool, viewer: Option<&User>) -> Result<Response> {
475591 let Some(package) = self.nuget_package(workspace, id).await? else {
+305−2
11 //! What the RubyGems registry needs that does not touch the network: gem
22 //! names and versions, the registry's paths, the `Gem::Specification` read
3−//! from a `.gem` (a tar holding `metadata.gz`), and the compact index
4−//! Bundler reads: `versions`, `info/<gem>` and `names`.
3+//! from a `.gem` (a tar holding `metadata.gz`), the compact index
4+//! Bundler reads (`versions`, `info/<gem>` and `names`), and the full
5+//! index `gem install --source` reads: `specs.4.8.gz` and its latest and
6+//! pre-release kin, and each version's `quick/Marshal.4.8` specification.
57 //!
68 //! A version is keyed by its number and platform as the compact index
79 //! writes it (`1.0.0`, `1.0.0-x86_64-linux`), and keeps what its index
911
1012 use serde_json::{Value, json};
1113
14+use std::cmp::Ordering;
15+
1216 use crate::archive;
17+use crate::marshal::{self, Value as Ruby};
1318 use crate::yaml;
1419
1520 /// The longest gem name taken.
6166 /// `gems/<name>-<version>[-<platform>].gem`; the name and version are
6267 /// told apart by the handler, as names may hold `-`.
6368 Gem { stem: String },
69+ /// `specs.4.8.gz`, `latest_specs.4.8.gz` or `prerelease_specs.4.8.gz`.
70+ Specs(Specs),
71+ /// `quick/Marshal.4.8/<name>-<version>[-<platform>].gemspec.rz`: one
72+ /// version's specification.
73+ QuickSpec { stem: String },
6474 /// `api/v1/gems`: `gem push`.
6575 Push,
6676 /// `api/v1/gems/yank`: `gem yank`.
7989 "names" => GemRoute::Names,
8090 "api/v1/gems" => GemRoute::Push,
8191 "api/v1/gems/yank" => GemRoute::Yank,
92+ "specs.4.8.gz" => GemRoute::Specs(Specs::Released),
93+ "latest_specs.4.8.gz" => GemRoute::Specs(Specs::Latest),
94+ "prerelease_specs.4.8.gz" => GemRoute::Specs(Specs::Prerelease),
8295 other => {
96+ if let Some(file) = other.strip_prefix("quick/Marshal.4.8/") {
97+ let stem = file.strip_suffix(".gemspec.rz")?;
98+ if stem.contains('/') || candidates(stem).is_empty() {
99+ return None;
100+ }
101+ return Some((workspace, GemRoute::QuickSpec { stem: stem.to_owned() }));
102+ }
83103 if let Some(name) = other.strip_prefix("info/") {
84104 if !valid_name(name) {
85105 return None;
276296 out
277297 }
278298
299+/// Which of the full index's files: every released version, the highest
300+/// released version of each gem and platform, or every pre-release.
301+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
302+pub enum Specs {
303+ Released,
304+ Latest,
305+ Prerelease,
306+}
307+
308+/// A version's parts as `Gem::Version` compares them: `1.0.0.rc1` is
309+/// `1 0 0 rc 1`, and `1.0.0-beta` is `1.0.0.pre.beta`.
310+#[derive(Clone, Debug, PartialEq, Eq)]
311+enum Part {
312+ Number(u64),
313+ Word(String),
314+}
315+
316+fn parts(version: &str) -> Vec<Part> {
317+ let version = version.trim().replace('-', ".pre.");
318+ let mut out = Vec::new();
319+ for piece in version.split('.') {
320+ let mut rest = piece;
321+ while !rest.is_empty() {
322+ let digits = rest.bytes().next().is_some_and(|b| b.is_ascii_digit());
323+ let len = rest.bytes().take_while(|b| b.is_ascii_digit() == digits).count();
324+ let (run, after) = rest.split_at(len);
325+ out.push(if digits { Part::Number(run.parse().unwrap_or(u64::MAX)) } else { Part::Word(run.to_owned()) });
326+ rest = after;
327+ }
328+ }
329+ out
330+}
331+
332+/// `Gem::Version`'s order: by part, a missing part is 0, and a word (a
333+/// pre-release) is lower than any number.
334+pub fn compare(a: &str, b: &str) -> Ordering {
335+ let (a, b) = (parts(a), parts(b));
336+ for i in 0..a.len().max(b.len()) {
337+ let zero = Part::Number(0);
338+ let (x, y) = (a.get(i).unwrap_or(&zero), b.get(i).unwrap_or(&zero));
339+ let order = match (x, y) {
340+ (Part::Number(x), Part::Number(y)) => x.cmp(y),
341+ (Part::Word(x), Part::Word(y)) => x.cmp(y),
342+ (Part::Word(_), Part::Number(_)) => Ordering::Less,
343+ (Part::Number(_), Part::Word(_)) => Ordering::Greater,
344+ };
345+ if order != Ordering::Equal {
346+ return order;
347+ }
348+ }
349+ Ordering::Equal
350+}
351+
352+/// One version in the full index: its gem, number and platform.
353+#[derive(Clone, Debug, PartialEq, Eq)]
354+pub struct Tuple {
355+ pub name: String,
356+ pub number: String,
357+ pub platform: String,
358+}
359+
360+impl Tuple {
361+ /// What a version keeps says its number and platform.
362+ pub fn of(name: &str, key: &str, stored: &Value) -> Tuple {
363+ let platform = stored["platform"].as_str().filter(|p| !p.is_empty()).unwrap_or("ruby").to_owned();
364+ let number = stored["number"].as_str().map(str::to_owned).unwrap_or_else(|| {
365+ key.strip_suffix(&format!("-{platform}")).unwrap_or(key).to_owned()
366+ });
367+ Tuple { name: name.to_owned(), number, platform }
368+ }
369+}
370+
371+/// A `Gem::Version`, marshalled as its `marshal_dump`: `[version]`.
372+fn gem_version(number: &str) -> Ruby {
373+ Ruby::UserMarshal { class: "Gem::Version".into(), data: Box::new(Ruby::Array(vec![Ruby::str(number)])) }
374+}
375+
376+/// A full index file: each version of `tuples` that `which` lists, as
377+/// `[name, Gem::Version, platform]`, marshalled and gzipped.
378+pub fn specs_file(which: Specs, tuples: &[Tuple]) -> Vec<u8> {
379+ let mut chosen: Vec<&Tuple> = match which {
380+ Specs::Released => tuples.iter().filter(|t| !is_prerelease(&t.number)).collect(),
381+ Specs::Prerelease => tuples.iter().filter(|t| is_prerelease(&t.number)).collect(),
382+ Specs::Latest => {
383+ let mut highest: Vec<&Tuple> = Vec::new();
384+ for tuple in tuples.iter().filter(|t| !is_prerelease(&t.number)) {
385+ match highest.iter_mut().find(|h| h.name == tuple.name && h.platform == tuple.platform) {
386+ Some(kept) if compare(&tuple.number, &kept.number) == Ordering::Greater => *kept = tuple,
387+ Some(_) => {}
388+ None => highest.push(tuple),
389+ }
390+ }
391+ highest
392+ }
393+ };
394+ chosen.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| compare(&a.number, &b.number)).then_with(|| a.platform.cmp(&b.platform)));
395+ let list = chosen
396+ .into_iter()
397+ .map(|t| Ruby::Array(vec![Ruby::str(&t.name), gem_version(&t.number), Ruby::str(&t.platform)]))
398+ .collect();
399+ archive::gzip(&marshal::dump(&Ruby::Array(list)))
400+}
401+
402+/// A `Gem::Requirement` from the index's form (`< 4&>= 2.0`), marshalled
403+/// as its `marshal_dump`: `[[[op, Gem::Version], ...]]`.
404+fn gem_requirement(text: Option<&str>) -> Ruby {
405+ let mut pairs: Vec<Ruby> = text
406+ .unwrap_or("")
407+ .split('&')
408+ .map(str::trim)
409+ .filter(|r| !r.is_empty())
410+ .map(|r| {
411+ let (op, number) = match r.split_once(' ') {
412+ Some((op, number)) => (op.trim(), number.trim()),
413+ None => ("=", r),
414+ };
415+ Ruby::Array(vec![Ruby::str(op), gem_version(number)])
416+ })
417+ .collect();
418+ if pairs.is_empty() {
419+ pairs.push(Ruby::Array(vec![Ruby::str(">="), gem_version("0")]));
420+ }
421+ Ruby::UserMarshal { class: "Gem::Requirement".into(), data: Box::new(Ruby::Array(vec![Ruby::Array(pairs)])) }
422+}
423+
424+/// A `Gem::Platform` (`x86_64-linux` is cpu `x86_64`, os `linux`), or the
425+/// string `ruby` for a pure-Ruby gem, as RubyGems marshals it.
426+fn gem_platform(platform: &str) -> Ruby {
427+ if platform == "ruby" {
428+ return Ruby::str("ruby");
429+ }
430+ let parts: Vec<&str> = platform.splitn(3, '-').collect();
431+ let (cpu, os, version) = match parts.as_slice() {
432+ [os] => (None, *os, None),
433+ [cpu, os] => (Some(*cpu), *os, None),
434+ [cpu, os, version, ..] => (Some(*cpu), *os, Some(*version)),
435+ [] => (None, platform, None),
436+ };
437+ Ruby::Object {
438+ class: "Gem::Platform".into(),
439+ ivars: vec![("@cpu".into(), Ruby::opt(cpu)), ("@os".into(), Ruby::str(os)), ("@version".into(), Ruby::opt(version))],
440+ }
441+}
442+
443+/// A version's `Gem::Specification`, from what it keeps, marshalled as
444+/// RubyGems' `_dump` writes it and deflated: the `.gemspec.rz` that
445+/// `gem install` reads before it downloads the gem.
446+pub fn quick_spec(name: &str, key: &str, stored: &Value, published_at: &str) -> Vec<u8> {
447+ let tuple = Tuple::of(name, key, stored);
448+ let text = |key: &str| stored[key].as_str().map(str::trim).filter(|t| !t.is_empty());
449+ let strings = |key: &str| Ruby::Array(texts(&stored[key]).into_iter().map(Ruby::Str).collect());
450+ let dependencies = stored["dependencies"]
451+ .as_array()
452+ .map(|deps| {
453+ deps.iter()
454+ .filter_map(|d| {
455+ let name = d["name"].as_str()?;
456+ let requirement = gem_requirement(d["requirement"].as_str());
457+ Some(Ruby::Object {
458+ class: "Gem::Dependency".into(),
459+ ivars: vec![
460+ ("@name".into(), Ruby::str(name)),
461+ ("@requirement".into(), requirement.clone()),
462+ ("@type".into(), Ruby::Symbol("runtime".into())),
463+ ("@prerelease".into(), Ruby::Bool(false)),
464+ ("@version_requirements".into(), requirement),
465+ ],
466+ })
467+ })
468+ .collect()
469+ })
470+ .unwrap_or_default();
471+ let mut metadata = Vec::new();
472+ if let Some(uri) = text("source_code_uri") {
473+ metadata.push((Ruby::str("source_code_uri"), Ruby::str(uri)));
474+ }
475+ let date = published_at.get(..10).filter(|d| d.len() == 10).unwrap_or("1980-01-02");
476+ // The fields of `Gem::Specification#_dump`, in its order.
477+ let fields = Ruby::Array(vec![
478+ Ruby::str(text("rubygems_version").unwrap_or("3.5.0")),
479+ Ruby::Int(4),
480+ Ruby::str(&tuple.name),
481+ gem_version(&tuple.number),
482+ Ruby::str(date),
483+ Ruby::str(text("summary").unwrap_or("")),
484+ gem_requirement(text("ruby")),
485+ gem_requirement(text("rubygems")),
486+ Ruby::str(&tuple.platform),
487+ Ruby::Array(dependencies),
488+ Ruby::str(""),
489+ Ruby::Nil,
490+ strings("authors"),
491+ Ruby::opt(text("description")),
492+ Ruby::opt(text("homepage")),
493+ Ruby::Bool(true),
494+ gem_platform(&tuple.platform),
495+ strings("licenses"),
496+ Ruby::Hash(metadata),
497+ ]);
498+ let spec = Ruby::UserDef { class: "Gem::Specification".into(), data: marshal::dump(&fields) };
499+ miniz_oxide::deflate::compress_to_vec_zlib(&marshal::dump(&spec), 6)
500+}
501+
279502 /// A value from a form body or query string (`gem_name=hello&version=1.0`).
280503 pub fn form_value(form: &str, key: &str) -> Option<String> {
281504 let url = worker::Url::parse(&format!("http://form.invalid/?{form}")).ok()?;
301524 assert!(!valid_version(bad), "{bad}");
302525 }
303526 assert!(is_prerelease("2.0.0.rc1") && !is_prerelease("2.0.0"));
527+ let mut sorted = vec!["1.10.0", "1.0.0", "1.0.0.rc1", "1.0", "1.2.0-beta.1", "1.2.0", "0.9"];
528+ sorted.sort_by(|a, b| compare(a, b));
529+ assert_eq!(sorted, ["0.9", "1.0.0.rc1", "1.0.0", "1.0", "1.2.0-beta.1", "1.2.0", "1.10.0"]);
304530 assert_eq!(key("1.0.0", "ruby"), "1.0.0");
305531 assert_eq!(key("1.0.0", "x86_64-linux"), "1.0.0-x86_64-linux");
306532 }
315541 assert_eq!(route("/-/rubygems/acme/api/v1/gems"), at(GemRoute::Push));
316542 assert_eq!(route("/-/rubygems/acme/api/v1/gems/yank"), at(GemRoute::Yank));
317543 assert_eq!(route("/-/rubygems/acme/gems/hello.gem"), None, "no version");
544+ assert_eq!(route("/-/rubygems/acme/specs.4.8.gz"), at(GemRoute::Specs(Specs::Released)));
545+ assert_eq!(route("/-/rubygems/acme/latest_specs.4.8.gz"), at(GemRoute::Specs(Specs::Latest)));
546+ assert_eq!(route("/-/rubygems/acme/prerelease_specs.4.8.gz"), at(GemRoute::Specs(Specs::Prerelease)));
547+ assert_eq!(
548+ route("/-/rubygems/acme/quick/Marshal.4.8/hello-world-0.1.0.gemspec.rz"),
549+ at(GemRoute::QuickSpec { stem: "hello-world-0.1.0".into() })
550+ );
551+ assert_eq!(route("/-/rubygems/acme/quick/Marshal.4.8/hello.gemspec.rz"), None, "no version");
318552 assert_eq!(route("/-/rubygems/acme/info/a b"), None);
319553 assert_eq!(route("/-/rubygems/acme/other"), None);
320554 assert_eq!(route("/-/rubygems/acme"), None);
436670 assert_eq!(form_value("gem_name=a%2Bb", "gem_name").as_deref(), Some("a+b"));
437671 assert_eq!(form_value("version=1", "platform"), None);
438672 }
673+
674+ fn tuples() -> Vec<Tuple> {
675+ let tuple = |name: &str, number: &str, platform: &str| Tuple { name: name.into(), number: number.into(), platform: platform.into() };
676+ vec![
677+ tuple("hello", "0.2.0", "ruby"),
678+ tuple("hello", "0.10.0", "ruby"),
679+ tuple("hello", "1.0.0.rc1", "ruby"),
680+ tuple("hello", "0.10.0", "java"),
681+ tuple("abc", "1.0.0", "ruby"),
682+ ]
683+ }
684+
685+ #[test]
686+ fn the_full_index_lists_versions_as_tuples() {
687+ let file = specs_file(Specs::Latest, &tuples());
688+ let bytes = archive::gunzip(&file, 1 << 20).unwrap();
689+ // By name, then version and platform: hello's highest of each.
690+ let dumped = marshal::dump(&Ruby::Array(vec![
691+ Ruby::Array(vec![Ruby::str("abc"), gem_version("1.0.0"), Ruby::str("ruby")]),
692+ Ruby::Array(vec![Ruby::str("hello"), gem_version("0.10.0"), Ruby::str("java")]),
693+ Ruby::Array(vec![Ruby::str("hello"), gem_version("0.10.0"), Ruby::str("ruby")]),
694+ ]));
695+ assert_eq!(bytes, dumped);
696+ let released = archive::gunzip(&specs_file(Specs::Released, &tuples()), 1 << 20).unwrap();
697+ assert_eq!(released.windows(5).filter(|w| *w == b"hello").count(), 3, "every released version");
698+ let pre = archive::gunzip(&specs_file(Specs::Prerelease, &tuples()), 1 << 20).unwrap();
699+ assert!(pre.windows(9).any(|w| w == b"1.0.0.rc1"));
700+ assert!(!pre.windows(6).any(|w| w == b"0.10.0"));
701+ }
702+
703+ #[test]
704+ fn a_quick_spec_is_a_deflated_specification() {
705+ let stored = json!({
706+ "name": "hello-world", "number": "0.2.0", "platform": "ruby", "summary": "Says hello",
707+ "authors": ["Ada"], "licenses": ["MIT"], "homepage": "https://g1t.sh/acme/hello-world",
708+ "dependencies": [{ "name": "rack", "requirement": "< 4&>= 2.0" }], "ruby": ">= 3.0.0", "rubygems": null,
709+ });
710+ let rz = quick_spec("hello-world", "0.2.0", &stored, "2026-10-07T01:02:03.000Z");
711+ let bytes = miniz_oxide::inflate::decompress_to_vec_zlib(&rz).unwrap();
712+ assert_eq!(&bytes[..3], b"\x04\x08u");
713+ assert!(bytes.windows(18).any(|w| w == b"Gem::Specification"));
714+ assert!(bytes.windows(10).any(|w| w == b"2026-10-07"));
715+ assert!(bytes.windows(15).any(|w| w == b"Gem::Dependency"));
716+ // A gem built for a platform names it as a Gem::Platform too.
717+ let native = quick_spec("native", "1.0.0-x86_64-linux", &json!({ "number": "1.0.0", "platform": "x86_64-linux" }), "");
718+ let bytes = miniz_oxide::inflate::decompress_to_vec_zlib(&native).unwrap();
719+ assert!(bytes.windows(13).any(|w| w == b"Gem::Platform"));
720+ assert_eq!(Tuple::of("native", "1.0.0-x86_64-linux", &json!({})).number, "1.0.0-x86_64-linux", "no platform kept: the key");
721+ assert_eq!(Tuple::of("native", "1.0.0-java", &json!({ "platform": "java" })).number, "1.0.0");
722+ }
723+
724+ /// Writes the full index for `tuples()` and a quick spec where a real
725+ /// Ruby can read them: `G1T_MARSHAL_OUT=<dir> cargo test marshal_files`,
726+ /// then `ruby -e` over the files (see the RubyGems guide's notes).
727+ #[test]
728+ fn marshal_files_for_ruby() {
729+ let Ok(dir) = std::env::var("G1T_MARSHAL_OUT") else { return };
730+ let dir = std::path::Path::new(&dir);
731+ std::fs::write(dir.join("specs.4.8.gz"), specs_file(Specs::Released, &tuples())).unwrap();
732+ std::fs::write(dir.join("latest_specs.4.8.gz"), specs_file(Specs::Latest, &tuples())).unwrap();
733+ let stored = json!({
734+ "name": "hello-world", "number": "0.2.0", "platform": "ruby", "summary": "Says hello", "description": "Says hello.",
735+ "authors": ["Ada"], "licenses": ["MIT"], "homepage": "https://g1t.sh/acme/hello-world", "source_code_uri": "https://g1t.sh/acme/hello-world",
736+ "dependencies": [{ "name": "rack", "requirement": "< 4&>= 2.0" }, { "name": "json", "requirement": ">= 0" }], "ruby": ">= 3.0.0",
737+ });
738+ std::fs::write(dir.join("hello-world-0.2.0.gemspec.rz"), quick_spec("hello-world", "0.2.0", &stored, "2026-10-07T00:00:00.000Z")).unwrap();
739+ let native = json!({ "name": "native", "number": "1.0.0", "platform": "x86_64-linux", "dependencies": [] });
740+ std::fs::write(dir.join("native-1.0.0-x86_64-linux.gemspec.rz"), quick_spec("native", "1.0.0-x86_64-linux", &native, "2026-10-07T00:00:00.000Z")).unwrap();
741+ }
439742 }
+47−1
66 //!
77 //! Bundler installs from the compact index (`versions`, `info/<gem>`,
88 //! `names`), made from the versions on each read, with each file's MD5 as
9−//! its `ETag` as Bundler checks it. A `.gem` is stored once, by its
9+//! its `ETag` as Bundler checks it. `gem install --source` and `gem search`
10+//! read the full index: `specs.4.8.gz` (and `latest_` and `prerelease_`),
11+//! and a version's `quick/Marshal.4.8/<gem>.gemspec.rz`, made from what
12+//! each version keeps, in Ruby's Marshal format. A `.gem` is stored once, by its
1013 //! SHA-256, which is also its index `checksum`. `gem yank` takes a version
1114 //! out of the index; its file stays for lockfiles that name it.
1215
6669 Ok(Response::from_body(body)?.with_headers(headers))
6770 }
6871
72+/// A full index file or a specification, which `gem` reads as bytes.
73+fn binary(bytes: Vec<u8>, head: bool, cache: &str) -> Result<Response> {
74+ let headers = Headers::new();
75+ headers.set("content-type", "application/octet-stream")?;
76+ headers.set("content-length", &bytes.len().to_string())?;
77+ headers.set("cache-control", cache)?;
78+ let body = if head { ResponseBody::Empty } else { ResponseBody::Body(bytes) };
79+ Ok(Response::from_body(body)?.with_headers(headers))
80+}
81+
6982 /// A version's line in the index.
7083 fn line(row: &VersionRow) -> String {
7184 let checksum = Digest::parse(&row.digest).map(|d| d.hex().to_owned()).unwrap_or_default();
128141 GemRoute::Names if read => self.gem_names(&request, workspace, viewer, head).await,
129142 GemRoute::Info { name } if read => self.gem_info(&request, workspace, &name, viewer, head).await,
130143 GemRoute::Gem { stem } if read => self.gem_download(workspace, &stem, viewer, head, ctx).await,
144+ GemRoute::Specs(which) if read => self.gem_specs(workspace, which, viewer, head).await,
145+ GemRoute::QuickSpec { stem } if read => self.gem_quick_spec(workspace, &stem, viewer, head).await,
131146 GemRoute::Push if method == Method::Post => self.gem_push(&mut request, workspace, viewer).await,
132147 GemRoute::Yank if method == Method::Delete => self.gem_yank(&mut request, url, workspace, viewer).await,
133148 _ => error(405, "Not a method this address takes."),
224239 index_file(request, rubygems::info(&rows.iter().map(line).collect::<Vec<_>>()), head)
225240 }
226241
242+ /// A full index file: the versions in the index of every gem the
243+ /// viewer may see, as `[name, Gem::Version, platform]`.
244+ async fn gem_specs(&self, workspace: &str, which: rubygems::Specs, viewer: Option<&User>, head: bool) -> Result<Response> {
245+ let gems = match self.gem_index(workspace, viewer).await? {
246+ Ok(gems) => gems,
247+ Err(refused) => return Ok(refused),
248+ };
249+ let tuples: Vec<rubygems::Tuple> =
250+ gems.iter().flat_map(|(package, rows)| rows.iter().map(|row| rubygems::Tuple::of(&package.name, &row.version, &row.meta()))).collect();
251+ binary(rubygems::specs_file(which, &tuples), head, "no-cache")
252+ }
253+
254+ /// A version's specification, marshalled and deflated, which `gem
255+ /// install` reads before the gem. Yanked versions' too, as their files.
256+ async fn gem_quick_spec(&self, workspace: &str, stem: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
257+ for (name, key) in rubygems::candidates(stem).into_iter().rev() {
258+ let Some(package) = self.db.package(workspace, RUBYGEMS, &name).await?.filter(|p| !p.hidden()) else {
259+ continue;
260+ };
261+ if let Some(refusal) = self.gem_check(viewer, &package, Action::Pull).await? {
262+ return Ok(refusal);
263+ }
264+ let Some(row) = self.db.version_named(&package.id, &key).await? else {
265+ continue;
266+ };
267+ let spec = rubygems::quick_spec(&package.name, &row.version, &row.meta(), &row.published_at);
268+ return binary(spec, head, "max-age=300");
269+ }
270+ self.gem_absent(workspace, viewer).await
271+ }
272+
227273 /// A `.gem`, yanked ones too: a lockfile may still name them.
228274 async fn gem_download(&self, workspace: &str, stem: &str, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
229275 for (name, key) in rubygems::candidates(stem).into_iter().rev() {