NuGet: symbol packages (.snupkg) pushed to SymbolPackagePublish, a symbol server serving their portable PDBs by key, Symbols on the package page; each .nupkg download counts for its version
10 files+466−310/10 viewed
| 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 |
| 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)] |
| 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); |
| 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 | + | ||
| 1195 | 1233 | /// A workspace's Maven artifacts of one groupId (`com.acme:*`), by | |
| 1196 | 1234 | /// name: those named from `com.acme:` up to `com.acme;`, the | |
| 1197 | 1235 | /// character after `:`. |
| 63 | 63 | const SWEEP_BATCH: u32 = 200; | |
| 64 | 64 | ||
| 65 | 65 | thread_local! { | |
| 66 | − | /// Pulls counted since the last write, by package: written at most | |
| 67 | − | /// every few seconds, so a busy image costs one write, not one a pull. | |
| 68 | − | /// What an isolate holds when it goes away is lost: the count is | |
| 69 | − | /// approximate. | |
| 70 | − | 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<(String, Option<String>), u64>, u64)> = RefCell::new((HashMap::new(), 0)); | |
| 71 | 71 | } | |
| 72 | 72 | const DOWNLOADS_FLUSH_MS: u64 = 10_000; | |
| 73 | 73 | ||
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | 288 | fn count_download(&self, package_id: &str, ctx: &Context) { | |
| 289 | + | self.count_downloads(package_id, None, ctx); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// A download of one version, counted for it and its package. | |
| 293 | + | fn count_version_download(&self, package_id: &str, version_id: &str, ctx: &Context) { | |
| 294 | + | self.count_downloads(package_id, Some(version_id), ctx); | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | fn count_downloads(&self, package_id: &str, version_id: Option<&str>, ctx: &Context) { | |
| 289 | 298 | let due = DOWNLOADS.with(|counts| { | |
| 290 | 299 | let mut counts = counts.borrow_mut(); | |
| 291 | − | *counts.0.entry(package_id.to_owned()).or_default() += 1; | |
| 300 | + | *counts.0.entry((package_id.to_owned(), version_id.map(str::to_owned))).or_default() += 1; | |
| 292 | 301 | let now = now_ms(); | |
| 293 | 302 | if now.saturating_sub(counts.1) < DOWNLOADS_FLUSH_MS { | |
| 294 | 303 | return None; | |
| 457 | 466 | } else { | |
| 458 | 467 | version.deprecated | |
| 459 | 468 | }, | |
| 469 | + | symbols: meta["symbols"] == true, | |
| 470 | + | downloads: (row.package.ecosystem == "nuget").then_some(version.downloads), | |
| 460 | 471 | } | |
| 461 | 472 | }) | |
| 462 | 473 | .collect(); |
| 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 { |