Merge branch 'worktree-agent-ac1de8a731938ed81'
20 files+1453−740/20 viewed
| 42 | 42 | access works; one with scopes needs `packages:read` to add private crates | |
| 43 | 43 | and `packages:write` to publish and yank them. | |
| 44 | 44 | ||
| 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. | |
| 48 | 49 | ||
| 49 | 50 | ## Publish | |
| 50 | 51 | ||
| 149 | 150 | ## In workflows | |
| 150 | 151 | ||
| 151 | 152 | 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: | |
| 153 | 156 | ||
| 154 | 157 | ```yaml | |
| 155 | 158 | jobs: | |
| 165 | 168 | - run: cargo publish --registry acme | |
| 166 | 169 | ``` | |
| 167 | 170 | ||
| 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. | |
| 171 | 181 | ||
| 172 | 182 | ## Size | |
| 173 | 183 | ||
| 181 | 191 | ||
| 182 | 192 | | Error | Means | | |
| 183 | 193 | | --- | --- | | |
| 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`. | | |
| 186 | 196 | | `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. | | |
| 187 | 197 | | `404` | No such crate or version, or a private one you cannot see. | | |
| 188 | 198 | | `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. | |
| 122 | 122 | `credentials(PasswordCredentials::class)` reads `acmeUsername` and | |
| 123 | 123 | `acmePassword` from `gradle.properties` or from the environment as | |
| 124 | 124 | `ORG_GRADLE_PROJECT_acmeUsername` and `ORG_GRADLE_PROJECT_acmePassword`. | |
| 125 | − | Gradle's Gradle Module Metadata (`.module`) is uploaded and served beside | |
| 126 | − | the POM. | |
| 127 | 125 | ||
| 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 | + | ||
| 128 | 135 | ## Which repository an artifact belongs to | |
| 129 | 136 | ||
| 130 | 137 | The first file deployed makes the artifact. When a repository of the | |
| 182 | 189 | -DremoteRepositories=acme::default::https://g1t.sh/-/maven/acme/ | |
| 183 | 190 | ``` | |
| 184 | 191 | ||
| 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 | + | ||
| 185 | 230 | ## SNAPSHOTs | |
| 186 | 231 | ||
| 187 | 232 | A version ending in `-SNAPSHOT` takes a new build each time it is | |
| 202 | 247 | uploaded are checked against it, and a mismatch is refused with `400`. | |
| 203 | 248 | - **`maven-metadata.xml` is made by g1t** from the versions there, so it | |
| 204 | 249 | 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. | |
| 207 | 253 | - **The deploy's last step publishes it.** Maven and Gradle upload the | |
| 208 | 254 | artifact's `maven-metadata.xml` after its files. Then each version (or | |
| 209 | 255 | SNAPSHOT build) whose POM arrived in the deploy is published: it is an |
| 82 | 82 | [who can see and publish a package](/guides/packages/#who-can-see-and-publish-a-package). | |
| 83 | 83 | ||
| 84 | 84 | 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: | |
| 86 | 113 | ||
| 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 | + | ||
| 87 | 129 | ## Restore | |
| 88 | 130 | ||
| 89 | 131 | ```sh | |
| 177 | 219 | | --- | --- | | |
| 178 | 220 | | `401` | No credentials or API key, or a wrong or expired token. Check the source's username and password, or the `--api-key`. | | |
| 179 | 221 | | `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. | | |
| 183 | 225 | | `413` | The `.nupkg` is over 100 MB. | |
| 17 | 17 | | npm | `https://g1t.sh/-/npm/`, for the scope `@<workspace>` | [npm](/guides/npm/) | | |
| 18 | 18 | | Cargo | `sparse+https://g1t.sh/-/cargo/<workspace>/index/`, a registry per workspace | [Cargo](/guides/cargo/) | | |
| 19 | 19 | | 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/) | | |
| 22 | 22 | | Composer | `https://g1t.sh/-/composer/<workspace>/`, from the workspace's repositories | [Composer](/guides/composer/) | | |
| 23 | 23 | | Go | `g1t.sh/<workspace>/<repo>`, straight from git | [Go modules](/guides/go/) | | |
| 24 | 24 | ||
| 101 | 101 | ||
| 102 | 102 | Publishing a version, deleting a version and deleting a package are | |
| 103 | 103 | [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 | |
| 106 | 106 | `package.published`, `package.version_deleted`, `package.deleted` and | |
| 107 | 107 | `package.visibility_changed`, which [webhooks](/guides/webhooks/) can be | |
| 108 | 108 | sent: a linked package's go to its repository's webhooks and its |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | 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. | |
| 11 | 12 | ||
| 12 | 13 | ```text | |
| 13 | 14 | https://g1t.sh/-/rubygems/<workspace>/ | |
| 96 | 97 | `bundle install` then reads the compact index and downloads each `.gem`, | |
| 97 | 98 | which it checks against the SHA-256 the index names. | |
| 98 | 99 | ||
| 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 | + | ||
| 99 | 118 | ## Names and versions | |
| 100 | 119 | ||
| 101 | 120 | A gem's name is letters, digits, `.`, `-` and `_`, with at least one | |
| 114 | 133 | GEM_HOST_API_KEY=<token> gem yank http-client --version 0.3.1 --host https://g1t.sh/-/rubygems/acme | |
| 115 | 134 | ``` | |
| 116 | 135 | ||
| 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, | |
| 118 | 138 | but its `.gem` is still downloaded for a `Gemfile.lock` that names it. | |
| 119 | 139 | Yanking needs what pushing does. The gem's page marks yanked versions, and | |
| 120 | 140 | someone with Admin on the linked repository (an owner, for the | |
| 127 | 147 | ||
| 128 | 148 | | The workspace's gems | Without credentials | With credentials | | |
| 129 | 149 | | --- | --- | --- | | |
| 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. | | |
| 132 | 152 | ||
| 133 | 153 | A private gem you cannot see looks exactly like one that does not exist. | |
| 134 | 154 | ||
| 163 | 183 | ||
| 164 | 184 | | Error | Means | | |
| 165 | 185 | | --- | --- | | |
| 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`. | | |
| 167 | 187 | | `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. | | |
| 168 | 188 | | `404` | No such gem or version, or a private one you cannot see. | | |
| 169 | 189 | | `409` | That version is already pushed, or its name is taken by a gem named in another case. | |
| 230 | 230 | Deprecated | |
| 231 | 231 | </Badge> | |
| 232 | 232 | )} | |
| 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 | + | )} | |
| 233 | 238 | </div> | |
| 234 | 239 | {version.deprecated && <p className="text-xs text-muted">{version.deprecated}</p>} | |
| 235 | 240 | <p className="flex flex-wrap gap-x-3 text-xs text-faint tabular-nums"> | |
| 236 | 241 | <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 | + | )} | |
| 237 | 247 | {version.platforms.length > 0 && <span>{version.platforms.join(", ")}</span>} | |
| 238 | 248 | {attached.length > 0 && ( | |
| 239 | 249 | <span title={attached.map((a) => a.artifact_type ?? a.media_type ?? "artifact").join(", ")}> |
| 140 | 140 | /// npm: why the version should no longer be used, when it is deprecated. | |
| 141 | 141 | #[serde(default)] | |
| 142 | 142 | 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>, | |
| 143 | 149 | } | |
| 144 | 150 | ||
| 145 | 151 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 8 | 8 | This is the design every phase builds to. Each ecosystem's own guide (apps/docs) says how to use | |
| 9 | 9 | it; this says how it works. | |
| 10 | 10 | ||
| 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 | + | ||
| 11 | 32 | ## Principles | |
| 12 | 33 | ||
| 13 | 34 | - **One service.** `services/packages` (Rust Worker) owns every registry: its own D1 database | |
| 62 | 83 | ||
| 63 | 84 | ### Containers (OCI Distribution 1.1) | |
| 64 | 85 | ||
| 86 | + | Built. | |
| 87 | + | ||
| 65 | 88 | - Image names: `g1t.sh/<workspace>/<name>[:tag]`, `<name>` may contain `/`. Usually the | |
| 66 | 89 | repository's name, and then linked to it. | |
| 67 | 90 | - `GET /v2/` answers 401 with `WWW-Authenticate: Bearer realm="https://g1t.sh/v2/token", | |
| 86 | 109 | ||
| 87 | 110 | ### npm | |
| 88 | 111 | ||
| 112 | + | Built, except the proxy of unscoped packages. | |
| 113 | + | ||
| 89 | 114 | - Registry `https://g1t.sh/-/npm/`; scope = workspace: `@<workspace>/<name>`. | |
| 90 | 115 | `.npmrc`: `@acme:registry=https://g1t.sh/-/npm/` and `//g1t.sh/-/npm/:_authToken=<token>`. | |
| 91 | 116 | - `GET /@scope/name` (packument, abbreviated with `Accept: application/vnd.npm.install-v1+json`), | |
| 92 | 117 | `GET` tarballs, `PUT /@scope/name` (publish: JSON with the tarball attached), dist-tags, | |
| 93 | 118 | deprecate, unpublish (within 72 hours or with Admin), `GET /-/whoami`. | |
| 94 | 119 | - 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). | |
| 96 | 121 | ||
| 97 | 122 | ### Composer | |
| 98 | 123 | ||
| 124 | + | Built, except the Packagist mirror. | |
| 125 | + | ||
| 99 | 126 | - Per workspace: `https://g1t.sh/-/composer/<workspace>/` with `packages.json` naming | |
| 100 | 127 | `metadata-url` `/p2/%package%.json` and `available-packages`. | |
| 101 | 128 | - Versions come from **the workspace's repositories themselves**: a repository with a | |
| 104 | 131 | kept by commit. Pushing a tag publishes; nothing to upload. | |
| 105 | 132 | - Auth: `composer config --auth http-basic.g1t.sh <you> <token>` (`auth.json`). | |
| 106 | 133 | - 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). | |
| 108 | 135 | ||
| 109 | 136 | ### Cargo | |
| 110 | 137 | ||
| 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` | |
| 112 | 141 | (`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. | |
| 115 | 150 | ||
| 116 | 151 | ### Go | |
| 117 | 152 | ||
| 153 | + | Built from git; the module proxy is not built. | |
| 154 | + | ||
| 118 | 155 | - `go get g1t.sh/<workspace>/<repo>` works from git: repository pages answer `?go-get=1` with the | |
| 119 | 156 | `go-import` meta tag. Private modules need `GOPRIVATE=g1t.sh/<workspace>` and a token in | |
| 120 | 157 | `.netrc` (as for git). | |
| 121 | 158 | - 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). | |
| 123 | 160 | ||
| 124 | 161 | ### Maven | |
| 125 | 162 | ||
| 163 | + | Built, for Maven and Gradle. | |
| 164 | + | ||
| 126 | 165 | - Per workspace: `https://g1t.sh/-/maven/<workspace>/`, the standard layout | |
| 127 | 166 | (`com/acme/web/1.0.0/web-1.0.0.jar`). A package is an artifact, named | |
| 128 | 167 | `groupId:artifactId`; a version holds every file uploaded into its | |
| 135 | 174 | and kept by digest (`checksums`); `.md5`, `.sha1`, `.sha256`, `.sha512` | |
| 136 | 175 | are answered from them, and uploaded ones are checked, not kept. | |
| 137 | 176 | - `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. | |
| 141 | 188 | - The POM is the version's record: its coordinates must | |
| 142 | 189 | match its path, its description becomes the package's for the highest | |
| 143 | 190 | release, and on a new artifact its `<scm><url>` may link the repository. | |
| 147 | 194 | ||
| 148 | 195 | ### NuGet | |
| 149 | 196 | ||
| 197 | + | Built. | |
| 198 | + | ||
| 150 | 199 | - Per workspace: `https://g1t.sh/-/nuget/<workspace>/v3/index.json` naming | |
| 151 | 200 | `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`. | |
| 153 | 203 | - `dotnet nuget push`: a `PUT` of a multipart body with the `.nupkg`, | |
| 154 | 204 | `X-NuGet-ApiKey` a g1t token. The `.nuspec` (read from the zip) gives the | |
| 155 | 205 | id, version (normalized as NuGet does), description, dependency groups and | |
| 159 | 209 | nuget.org does; `POST` lists again. Unlisted versions stay in the flat | |
| 160 | 210 | container and registration (`listed: false`), not in search. | |
| 161 | 211 | - 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. | |
| 162 | 222 | ||
| 163 | 223 | ### RubyGems | |
| 164 | 224 | ||
| 225 | + | Built. | |
| 226 | + | ||
| 165 | 227 | - Per workspace: `https://g1t.sh/-/rubygems/<workspace>/`. `gem push` | |
| 166 | 228 | (`POST /api/v1/gems`, the token as the whole `Authorization` header), | |
| 167 | 229 | `gem yank` (`DELETE /api/v1/gems/yank`), downloads at | |
| 173 | 235 | - The gem's `metadata.gz` (YAML, in the `.gem` tar) gives the name, | |
| 174 | 236 | version, platform and runtime dependencies. A version is keyed as the | |
| 175 | 237 | 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. | |
| 179 | 248 | ||
| 180 | 249 | ### Later | |
| 181 | 250 | ||
| 182 | − | PyPI. | |
| 251 | + | PyPI (not built). | |
| 183 | 252 | ||
| 184 | 253 | ## Billing | |
| 185 | 254 | ||
| 222 | 291 | 4. **Cargo.** | |
| 223 | 292 | 5. **Maven, NuGet, RubyGems.** | |
| 224 | 293 | 6. **Mirrors** (Packagist, npm). | |
| 294 | + | ||
| 295 | + | Phases 1 to 5 are built (see [Status](#status)); 6 is not. |
| 62 | 62 | published_at: string; | |
| 63 | 63 | /** npm: why the version should no longer be used, when it is deprecated. */ | |
| 64 | 64 | 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; | |
| 65 | 69 | }; | |
| 66 | 70 | ||
| 67 | 71 | export type PackageTag = { tag: string; digest: string; updated_at: string }; |
| 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); |
| 87 | 87 | Ok(data) | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 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 | + | ||
| 90 | 99 | /// The bytes of a gzip file, inflated, up to `limit`. | |
| 91 | 100 | pub fn gunzip(bytes: &[u8], limit: usize) -> Result<Vec<u8>, String> { | |
| 92 | 101 | let bad = || "The file is not gzipped.".to_owned(); | |
| 200 | 209 | assert_eq!(files[1].1, b"data"); | |
| 201 | 210 | assert_eq!(gunzip(files[0].1, 1024).unwrap(), b"--- !ruby/object:Gem::Specification\nname: hello\n"); | |
| 202 | 211 | assert!(gunzip(b"plain text, not gzip at all", 1024).is_err()); | |
| 212 | + | assert_eq!(gunzip(&super::gzip(b"specs"), 1024).unwrap(), b"specs"); | |
| 203 | 213 | assert!(tar_files(&gem[..1538]).is_err(), "cut short"); | |
| 204 | 214 | } | |
| 205 | 215 | } |
| 188 | 188 | /// Cargo: 1 when the version is yanked. | |
| 189 | 189 | #[serde(default)] | |
| 190 | 190 | pub yanked: u32, | |
| 191 | + | /// Its own downloads, counted for NuGet's. | |
| 192 | + | #[serde(default)] | |
| 193 | + | pub downloads: u64, | |
| 191 | 194 | } | |
| 192 | 195 | ||
| 193 | 196 | impl VersionRow { | |
| 249 | 252 | pub media_type: Option<String>, | |
| 250 | 253 | } | |
| 251 | 254 | ||
| 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 | + | ||
| 252 | 263 | /// A file's other checksums, in hex, beside its SHA-256 digest. | |
| 253 | 264 | #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] | |
| 254 | 265 | pub struct Checksums { | |
| 293 | 304 | "id, workspace, ecosystem, name, repo_id, repo_name, visibility, description, created_by, created_at, updated_at, downloads, workspace_deleted_at"; | |
| 294 | 305 | /// Workspaces that are deleted, waiting to be purged or restored. | |
| 295 | 306 | 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"; | |
| 297 | 308 | ||
| 298 | 309 | pub struct Db { | |
| 299 | 310 | pub db: D1Database, | |
| 457 | 468 | Ok(()) | |
| 458 | 469 | } | |
| 459 | 470 | ||
| 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<()> { | |
| 461 | 473 | if counts.is_empty() { | |
| 462 | 474 | return Ok(()); | |
| 463 | 475 | } | |
| 464 | 476 | let mut batch = Vec::with_capacity(counts.len()); | |
| 465 | − | for (id, count) in counts { | |
| 477 | + | for ((id, version), count) in counts { | |
| 466 | 478 | 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 | + | } | |
| 467 | 482 | } | |
| 468 | 483 | self.db.batch(batch).await?; | |
| 469 | 484 | Ok(()) | |
| 1192 | 1207 | .results() | |
| 1193 | 1208 | } | |
| 1194 | 1209 | ||
| 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 | + | ||
| 1195 | 1249 | pub async fn forget_blob(&self, digest: &str) -> Result<()> { | |
| 1196 | 1250 | let d = [text(digest)]; | |
| 1197 | 1251 | self.db |
| 17 | 17 | mod digest; | |
| 18 | 18 | mod limits; | |
| 19 | 19 | mod manifest; | |
| 20 | + | mod marshal; | |
| 20 | 21 | mod maven; | |
| 21 | 22 | mod maven_http; | |
| 22 | 23 | mod names; | |
| 62 | 63 | const SWEEP_BATCH: u32 = 200; | |
| 63 | 64 | ||
| 64 | 65 | 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)); | |
| 70 | 71 | } | |
| 71 | 72 | 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>); | |
| 72 | 75 | ||
| 73 | 76 | thread_local! { | |
| 74 | 77 | /// What billing allows each workspace, as asked last, and when. | |
| 285 | 288 | } | |
| 286 | 289 | ||
| 287 | 290 | 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) { | |
| 288 | 300 | let due = DOWNLOADS.with(|counts| { | |
| 289 | 301 | 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; | |
| 291 | 303 | let now = now_ms(); | |
| 292 | 304 | if now.saturating_sub(counts.1) < DOWNLOADS_FLUSH_MS { | |
| 293 | 305 | return None; | |
| 456 | 468 | } else { | |
| 457 | 469 | version.deprecated | |
| 458 | 470 | }, | |
| 471 | + | symbols: meta["symbols"] == true, | |
| 472 | + | downloads: (row.package.ecosystem == "nuget").then_some(version.downloads), | |
| 459 | 473 | } | |
| 460 | 474 | }) | |
| 461 | 475 | .collect(); |
| 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 | + | } |
| 114 | 114 | ArtifactMetadata { group: String, artifact: String, checksum: Option<Checksum> }, | |
| 115 | 115 | /// `com/acme/web/1.0-SNAPSHOT/maven-metadata.xml`: a SNAPSHOT's builds. | |
| 116 | 116 | 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> }, | |
| 117 | 122 | /// `com/acme/web/1.0.0/web-1.0.0.jar`: one of a version's files. | |
| 118 | 123 | File { group: String, artifact: String, version: String, file: String, checksum: Option<Checksum> }, | |
| 119 | 124 | } | |
| 138 | 143 | let (artifact, version) = (parts[n - 3].to_owned(), parts[n - 2].to_owned()); | |
| 139 | 144 | return Some((workspace, MavenPath::VersionMetadata { group, artifact, version, checksum })); | |
| 140 | 145 | } | |
| 146 | + | if n == 2 && valid_group_part(parts[0]) { | |
| 147 | + | return Some((workspace, MavenPath::GroupMetadata { group: parts[0].to_owned(), checksum })); | |
| 148 | + | } | |
| 141 | 149 | if n < 3 || !valid_artifact(parts[n - 2]) { | |
| 142 | 150 | return None; | |
| 143 | 151 | } | |
| 405 | 413 | pub description: Option<String>, | |
| 406 | 414 | /// `<scm><url>`, else `<url>`: where its source is. | |
| 407 | 415 | pub source: Option<String>, | |
| 416 | + | /// `jar` when it names none; `maven-plugin` for a plugin. | |
| 417 | + | pub packaging: String, | |
| 408 | 418 | } | |
| 409 | 419 | ||
| 410 | 420 | /// Reads a POM, with the groupId and version a `<parent>` gives it. | |
| 423 | 433 | name: project.child_text("name"), | |
| 424 | 434 | description: project.child_text("description"), | |
| 425 | 435 | 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()), | |
| 426 | 437 | }) | |
| 427 | 438 | } | |
| 428 | 439 | ||
| 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 | + | ||
| 429 | 519 | #[cfg(test)] | |
| 430 | 520 | mod tests { | |
| 431 | 521 | use super::*; | |
| 474 | 564 | assert_eq!(route("/-/maven/acme/com/../web/1.0.0/web-1.0.0.jar"), None); | |
| 475 | 565 | assert_eq!(route("/-/maven/acme/com/acme/web/1.0.0/"), None); | |
| 476 | 566 | 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 | + | ); | |
| 477 | 571 | assert_eq!(route("/-/maven/"), None); | |
| 478 | 572 | } | |
| 479 | 573 | ||
| 574 | 668 | assert_eq!((pom.group.as_str(), pom.artifact.as_str(), pom.version.as_str()), ("com.acme", "web", "1.0.0")); | |
| 575 | 669 | assert_eq!(pom.description.as_deref(), Some("The web client")); | |
| 576 | 670 | 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"); | |
| 577 | 674 | assert!(read_pom(b"<project><artifactId>x</artifactId></project>").is_err(), "no groupId"); | |
| 578 | 675 | assert!(read_pom(b"not xml").is_err()); | |
| 579 | 676 | } | |
| 580 | 677 | ||
| 581 | 678 | #[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] | |
| 582 | 715 | fn checksums_are_hex_of_the_file() { | |
| 583 | 716 | assert_eq!(split_checksum("web-1.0.jar.sha1"), ("web-1.0.jar", Some(Checksum::Sha1))); | |
| 584 | 717 | assert_eq!(split_checksum("web-1.0.jar"), ("web-1.0.jar", None)); |
| 25 | 25 | use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result}; | |
| 26 | 26 | ||
| 27 | 27 | use crate::access::{self, Action}; | |
| 28 | + | use crate::archive; | |
| 28 | 29 | use crate::db::{Checksums, NewFile, NewVersion, PackageRow, VersionRow}; | |
| 29 | 30 | use crate::digest::Digest; | |
| 30 | 31 | use crate::maven::{self, Checksum, MavenPath}; | |
| 38 | 39 | const MAX_VERSIONS: u32 = 5000; | |
| 39 | 40 | /// The longest POM read for its description and source. | |
| 40 | 41 | 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; | |
| 41 | 47 | const DOCS: &str = "https://docs.g1t.sh/guides/maven/"; | |
| 42 | 48 | const TOKENS: &str = "https://g1t.sh/settings/tokens"; | |
| 43 | 49 | ||
| 106 | 112 | (MavenPath::ArtifactMetadata { group, artifact, checksum }, Method::Get | Method::Head) => { | |
| 107 | 113 | self.maven_metadata(workspace, &group, &artifact, None, checksum, viewer, head).await | |
| 108 | 114 | } | |
| 115 | + | (MavenPath::GroupMetadata { group, checksum }, Method::Get | Method::Head) => { | |
| 116 | + | self.maven_group_metadata(workspace, &group, None, checksum, viewer, head).await | |
| 117 | + | } | |
| 109 | 118 | (MavenPath::VersionMetadata { group, artifact, version, checksum }, Method::Get | Method::Head) => { | |
| 110 | 119 | self.maven_metadata(workspace, &group, &artifact, Some(&version), checksum, viewer, head).await | |
| 111 | 120 | } | |
| 120 | 129 | let name = maven::package_name(&group, &artifact); | |
| 121 | 130 | self.maven_checksum(&mut request, workspace, &name, &version, &file, checksum, viewer).await | |
| 122 | 131 | } | |
| 123 | − | (path @ (MavenPath::ArtifactMetadata { .. } | MavenPath::VersionMetadata { .. }), Method::Put) => { | |
| 132 | + | (path @ (MavenPath::ArtifactMetadata { .. } | MavenPath::VersionMetadata { .. } | MavenPath::GroupMetadata { .. }), Method::Put) => { | |
| 124 | 133 | self.maven_metadata_upload(&mut request, workspace, &path, viewer).await | |
| 125 | 134 | } | |
| 126 | 135 | _ => error(405, "Not a method this address takes. Versions are deleted on the package's page."), | |
| 172 | 181 | viewer: Option<&User>, | |
| 173 | 182 | head: bool, | |
| 174 | 183 | ) -> 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 | + | } | |
| 176 | 191 | return self.maven_absent(workspace, viewer).await; | |
| 177 | 192 | }; | |
| 178 | 193 | if let Some(refusal) = self.maven_check(viewer, &package, Action::Pull).await? { | |
| 184 | 199 | if versions.is_empty() { | |
| 185 | 200 | return self.maven_absent(workspace, viewer).await; | |
| 186 | 201 | } | |
| 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; | |
| 188 | 204 | } | |
| 189 | 205 | Some(version) => { | |
| 190 | 206 | let Some(row) = self.db.version_named(&package.id, version).await? else { | |
| 203 | 219 | } | |
| 204 | 220 | } | |
| 205 | 221 | ||
| 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 | + | ||
| 206 | 265 | /// One of a version's files, or a checksum of it. | |
| 207 | 266 | #[allow(clippy::too_many_arguments)] | |
| 208 | 267 | async fn maven_file( | |
| 360 | 419 | None | |
| 361 | 420 | }; | |
| 362 | 421 | ||
| 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 | + | ||
| 363 | 433 | let name = maven::package_name(group, artifact); | |
| 364 | 434 | let found = self.db.package(workspace, MAVEN, &name).await?; | |
| 365 | 435 | if found.as_ref().is_some_and(PackageRow::hidden) || (found.is_none() && self.db.workspace_hidden(workspace).await?) { | |
| 445 | 515 | ||
| 446 | 516 | if let Some(pom) = pom { | |
| 447 | 517 | 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?; | |
| 448 | 526 | } | |
| 449 | 527 | created() | |
| 450 | 528 | } | |
| 463 | 541 | metadata["name"] = json!(pom.name); | |
| 464 | 542 | metadata["description"] = json!(pom.description); | |
| 465 | 543 | metadata["source"] = json!(pom.source); | |
| 544 | + | metadata["packaging"] = json!(pom.packaging); | |
| 466 | 545 | self.db.set_version(&row.id, &digest.to_string(), &metadata.to_string()).await?; | |
| 467 | 546 | let versions = self.db.versions(&package.id, MAX_VERSIONS).await?; | |
| 468 | 547 | let releases: Vec<&str> = versions.iter().map(|v| v.version.as_str()).filter(|v| !maven::is_snapshot(v)).collect(); | |
| 579 | 658 | if let Err(refused) = self.maven_body(request).await? { | |
| 580 | 659 | return Ok(refused); | |
| 581 | 660 | } | |
| 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 | + | }; | |
| 584 | 667 | let target = match &found { | |
| 585 | 668 | Some(package) => TargetOf::package(package), | |
| 586 | 669 | // A plugin group's metadata names no artifact of its own. |
| 1 | 1 | //! What the NuGet feed needs that does not touch the network: package ids | |
| 2 | 2 | //! 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. | |
| 6 | 7 | //! | |
| 7 | 8 | //! A version keeps what the documents need from its `.nuspec` as its | |
| 8 | 9 | //! metadata, made once when it is pushed. Unlisting (`dotnet nuget | |
| 121 | 122 | pub enum Content { | |
| 122 | 123 | Nupkg, | |
| 123 | 124 | Nuspec, | |
| 125 | + | /// The symbol package, when one was pushed. | |
| 126 | + | Snupkg, | |
| 124 | 127 | } | |
| 125 | 128 | ||
| 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 | + | ||
| 126 | 140 | /// One of the feed's endpoints, under `/-/nuget/<workspace>/`. | |
| 127 | 141 | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 128 | 142 | pub enum NugetRoute { | |
| 142 | 156 | Push, | |
| 143 | 157 | /// `api/v2/package/<id>/<version>`: `DELETE` unlists, `POST` lists again. | |
| 144 | 158 | 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 }, | |
| 145 | 164 | } | |
| 146 | 165 | ||
| 147 | 166 | /// The workspace and endpoint a path is. Ids are checked; versions are | |
| 163 | 182 | let lower = format!("{}.{}", name.to_ascii_lowercase(), version.to_ascii_lowercase()); | |
| 164 | 183 | let file = if file.eq_ignore_ascii_case(&format!("{lower}.nupkg")) { | |
| 165 | 184 | Content::Nupkg | |
| 185 | + | } else if file.eq_ignore_ascii_case(&format!("{lower}.snupkg")) { | |
| 186 | + | Content::Snupkg | |
| 166 | 187 | } else if file.eq_ignore_ascii_case(&format!("{name}.nuspec")) { | |
| 167 | 188 | Content::Nuspec | |
| 168 | 189 | } else { | |
| 174 | 195 | ["v3", "registration", name, leaf] => NugetRoute::Leaf { id: id(name)?, version: leaf.strip_suffix(".json")?.to_owned() }, | |
| 175 | 196 | ["api", "v2", "package"] => NugetRoute::Push, | |
| 176 | 197 | ["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 | + | } | |
| 177 | 202 | _ => return None, | |
| 178 | 203 | }; | |
| 179 | 204 | Some((workspace, route)) | |
| 180 | 205 | } | |
| 181 | 206 | ||
| 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 | + | ||
| 182 | 308 | /// The `.nupkg` file in a `multipart/form-data` body, as `dotnet nuget | |
| 183 | 309 | /// push` sends it; a body that is not multipart is taken as the file. | |
| 184 | 310 | pub fn pushed_file<'a>(content_type: Option<&str>, body: &'a [u8]) -> Result<&'a [u8], String> { | |
| 228 | 354 | pub readme: Option<String>, | |
| 229 | 355 | pub require_license_acceptance: bool, | |
| 230 | 356 | pub groups: Vec<Group>, | |
| 357 | + | /// `<packageTypes>`: `SymbolsPackage` for a `.snupkg`. | |
| 358 | + | pub package_types: Vec<String>, | |
| 231 | 359 | } | |
| 232 | 360 | ||
| 233 | 361 | /// A dependency's `version` as a range: `1.0` (at least 1.0) is | |
| 277 | 405 | readme: metadata.child_text("readme"), | |
| 278 | 406 | require_license_acceptance: metadata.child_text("requireLicenseAcceptance").is_some_and(|v| v.eq_ignore_ascii_case("true")), | |
| 279 | 407 | 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(), | |
| 280 | 412 | }) | |
| 281 | 413 | } | |
| 282 | 414 | ||
| 353 | 485 | resource(query.clone(), "SearchQueryService/3.0.0-beta"), | |
| 354 | 486 | resource(query, "SearchQueryService/3.5.0"), | |
| 355 | 487 | resource(format!("{base}/api/v2/package"), "PackagePublish/2.0.0"), | |
| 488 | + | resource(format!("{base}/api/v2/symbolpackage"), "SymbolPackagePublish/4.9.0"), | |
| 356 | 489 | ], | |
| 357 | 490 | }) | |
| 358 | 491 | } | |
| 429 | 562 | "listed": listed.listed, | |
| 430 | 563 | "published": listed.published, | |
| 431 | 564 | "packageContent": at.content, | |
| 565 | + | "downloads": listed.downloads, | |
| 432 | 566 | }, | |
| 433 | 567 | "packageContent": at.content, | |
| 434 | 568 | "registration": at.registration, | |
| 536 | 670 | assert_eq!(route("/-/nuget/acme/api/v2/package/"), at(NugetRoute::Push)); | |
| 537 | 671 | 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() })); | |
| 538 | 672 | 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"); | |
| 539 | 685 | assert_eq!(route("/-/nuget/acme"), None); | |
| 540 | 686 | assert_eq!(route("/-/nuget/acme/v2"), None); | |
| 541 | 687 | } | |
| 599 | 745 | fn the_documents_are_nugets_shape() { | |
| 600 | 746 | let index = service_index("https://g1t.sh/-/nuget/acme"); | |
| 601 | 747 | 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"] { | |
| 603 | 749 | assert!(kinds.contains(&kind), "{kind}"); | |
| 604 | 750 | } | |
| 605 | 751 | let spec = read_nuspec(NUSPEC).unwrap(); | |
| 620 | 766 | assert_eq!(entry["dependencyGroups"][0]["targetFramework"], "net8.0"); | |
| 621 | 767 | assert_eq!(entry["dependencyGroups"][0]["dependencies"][1]["range"], "[1.0.0, 2.0.0)"); | |
| 622 | 768 | assert_eq!(page["items"][0]["catalogEntry"]["listed"], false); | |
| 769 | + | assert_eq!(page["items"][0]["catalogEntry"]["downloads"], 3, "each version's own"); | |
| 623 | 770 | let found = search_result(base, "Acme.Web", &versions).unwrap(); | |
| 624 | 771 | assert_eq!(found["version"], "1.2.0"); | |
| 625 | 772 | assert_eq!(found["versions"].as_array().unwrap().len(), 1, "unlisted versions are not searched"); | |
| 627 | 774 | assert_eq!(found["authors"], json!(["Ada", "Bo"])); | |
| 628 | 775 | assert!(search_result(base, "Acme.Web", &versions[..1]).is_none()); | |
| 629 | 776 | } | |
| 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 | + | } | |
| 630 | 831 | } |
| 7 | 7 | //! A `.nupkg` is stored once, by its SHA-256, with its `.nuspec` beside it; | |
| 8 | 8 | //! the flat container, registration and search documents are made from the | |
| 9 | 9 | //! 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>`. | |
| 11 | 17 | ||
| 12 | 18 | use g1t_contracts::User; | |
| 13 | 19 | use g1t_contracts::audit::AuditActor; | |
| 130 | 136 | NugetRoute::Push if method == Method::Put => self.nuget_push(&mut request, workspace, viewer).await, | |
| 131 | 137 | NugetRoute::Listing { id, version } if method == Method::Delete => self.nuget_listing(workspace, &id, &version, false, viewer).await, | |
| 132 | 138 | 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, | |
| 133 | 141 | _ => error(405, "Not a method this address takes."), | |
| 134 | 142 | } | |
| 135 | 143 | } | |
| 202 | 210 | let Some(row) = versions.iter().find(|v| v.version.to_ascii_lowercase() == wanted) else { | |
| 203 | 211 | return self.nuget_absent(workspace, viewer).await; | |
| 204 | 212 | }; | |
| 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 { | |
| 210 | 214 | return self.nuget_absent(workspace, viewer).await; | |
| 211 | 215 | }; | |
| 212 | 216 | let Some(digest) = Digest::parse(&kept.digest) else { | |
| 216 | 220 | return self.nuget_absent(workspace, viewer).await; | |
| 217 | 221 | }; | |
| 218 | 222 | 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" })?; | |
| 220 | 224 | headers.set("content-length", &blob.size.to_string())?; | |
| 221 | 225 | headers.set("cache-control", "max-age=31536000")?; | |
| 222 | 226 | if head { | |
| 226 | 230 | return self.nuget_absent(workspace, viewer).await; | |
| 227 | 231 | }; | |
| 228 | 232 | if file == Content::Nupkg { | |
| 229 | − | self.count_download(&package.id, ctx); | |
| 233 | + | self.count_version_download(&package.id, &row.id, ctx); | |
| 230 | 234 | } | |
| 231 | 235 | Ok(Response::from_body(got.body)?.with_headers(headers)) | |
| 232 | 236 | } | |
| 241 | 245 | let listed: Vec<Listed<'_>> = versions | |
| 242 | 246 | .iter() | |
| 243 | 247 | .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 }) | |
| 245 | 249 | .collect(); | |
| 246 | 250 | match version { | |
| 247 | 251 | None => json_response(&nuget::registration(base, &package.name, &listed), head), | |
| 287 | 291 | let mut listed: Vec<Listed<'_>> = rows | |
| 288 | 292 | .iter() | |
| 289 | 293 | .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 }) | |
| 291 | 295 | .collect(); | |
| 292 | 296 | listed.sort_by(|a, b| nuget::compare(a.version, b.version)); | |
| 293 | 297 | if let Some(mut result) = nuget::search_result(base, &package.name, &listed) { | |
| 470 | 474 | error(201, format!("{} {version} was pushed.", package.name)) | |
| 471 | 475 | } | |
| 472 | 476 | ||
| 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 | + | ||
| 473 | 589 | /// `dotnet nuget delete` unlists a version; a `POST` lists it again. | |
| 474 | 590 | async fn nuget_listing(&self, workspace: &str, id: &str, version: &str, listed: bool, viewer: Option<&User>) -> Result<Response> { | |
| 475 | 591 | let Some(package) = self.nuget_package(workspace, id).await? else { |
| 1 | 1 | //! What the RubyGems registry needs that does not touch the network: gem | |
| 2 | 2 | //! 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. | |
| 5 | 7 | //! | |
| 6 | 8 | //! A version is keyed by its number and platform as the compact index | |
| 7 | 9 | //! writes it (`1.0.0`, `1.0.0-x86_64-linux`), and keeps what its index | |
| 9 | 11 | ||
| 10 | 12 | use serde_json::{Value, json}; | |
| 11 | 13 | ||
| 14 | + | use std::cmp::Ordering; | |
| 15 | + | ||
| 12 | 16 | use crate::archive; | |
| 17 | + | use crate::marshal::{self, Value as Ruby}; | |
| 13 | 18 | use crate::yaml; | |
| 14 | 19 | ||
| 15 | 20 | /// The longest gem name taken. | |
| 61 | 66 | /// `gems/<name>-<version>[-<platform>].gem`; the name and version are | |
| 62 | 67 | /// told apart by the handler, as names may hold `-`. | |
| 63 | 68 | 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 }, | |
| 64 | 74 | /// `api/v1/gems`: `gem push`. | |
| 65 | 75 | Push, | |
| 66 | 76 | /// `api/v1/gems/yank`: `gem yank`. | |
| 79 | 89 | "names" => GemRoute::Names, | |
| 80 | 90 | "api/v1/gems" => GemRoute::Push, | |
| 81 | 91 | "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), | |
| 82 | 95 | 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 | + | } | |
| 83 | 103 | if let Some(name) = other.strip_prefix("info/") { | |
| 84 | 104 | if !valid_name(name) { | |
| 85 | 105 | return None; | |
| 276 | 296 | out | |
| 277 | 297 | } | |
| 278 | 298 | ||
| 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 | + | ||
| 279 | 502 | /// A value from a form body or query string (`gem_name=hello&version=1.0`). | |
| 280 | 503 | pub fn form_value(form: &str, key: &str) -> Option<String> { | |
| 281 | 504 | let url = worker::Url::parse(&format!("http://form.invalid/?{form}")).ok()?; | |
| 301 | 524 | assert!(!valid_version(bad), "{bad}"); | |
| 302 | 525 | } | |
| 303 | 526 | 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"]); | |
| 304 | 530 | assert_eq!(key("1.0.0", "ruby"), "1.0.0"); | |
| 305 | 531 | assert_eq!(key("1.0.0", "x86_64-linux"), "1.0.0-x86_64-linux"); | |
| 306 | 532 | } | |
| 315 | 541 | assert_eq!(route("/-/rubygems/acme/api/v1/gems"), at(GemRoute::Push)); | |
| 316 | 542 | assert_eq!(route("/-/rubygems/acme/api/v1/gems/yank"), at(GemRoute::Yank)); | |
| 317 | 543 | 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"); | |
| 318 | 552 | assert_eq!(route("/-/rubygems/acme/info/a b"), None); | |
| 319 | 553 | assert_eq!(route("/-/rubygems/acme/other"), None); | |
| 320 | 554 | assert_eq!(route("/-/rubygems/acme"), None); | |
| 436 | 670 | assert_eq!(form_value("gem_name=a%2Bb", "gem_name").as_deref(), Some("a+b")); | |
| 437 | 671 | assert_eq!(form_value("version=1", "platform"), None); | |
| 438 | 672 | } | |
| 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 | + | } | |
| 439 | 742 | } |
| 6 | 6 | //! | |
| 7 | 7 | //! Bundler installs from the compact index (`versions`, `info/<gem>`, | |
| 8 | 8 | //! `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 | |
| 10 | 13 | //! SHA-256, which is also its index `checksum`. `gem yank` takes a version | |
| 11 | 14 | //! out of the index; its file stays for lockfiles that name it. | |
| 12 | 15 | ||
| 66 | 69 | Ok(Response::from_body(body)?.with_headers(headers)) | |
| 67 | 70 | } | |
| 68 | 71 | ||
| 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 | + | ||
| 69 | 82 | /// A version's line in the index. | |
| 70 | 83 | fn line(row: &VersionRow) -> String { | |
| 71 | 84 | let checksum = Digest::parse(&row.digest).map(|d| d.hex().to_owned()).unwrap_or_default(); | |
| 128 | 141 | GemRoute::Names if read => self.gem_names(&request, workspace, viewer, head).await, | |
| 129 | 142 | GemRoute::Info { name } if read => self.gem_info(&request, workspace, &name, viewer, head).await, | |
| 130 | 143 | 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, | |
| 131 | 146 | GemRoute::Push if method == Method::Post => self.gem_push(&mut request, workspace, viewer).await, | |
| 132 | 147 | GemRoute::Yank if method == Method::Delete => self.gem_yank(&mut request, url, workspace, viewer).await, | |
| 133 | 148 | _ => error(405, "Not a method this address takes."), | |
| 224 | 239 | index_file(request, rubygems::info(&rows.iter().map(line).collect::<Vec<_>>()), head) | |
| 225 | 240 | } | |
| 226 | 241 | ||
| 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 | + | ||
| 227 | 273 | /// A `.gem`, yanked ones too: a lockfile may still name them. | |
| 228 | 274 | async fn gem_download(&self, workspace: &str, stem: &str, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> { | |
| 229 | 275 | for (name, key) in rubygems::candidates(stem).into_iter().rev() { |