Skip to content

g1t/services/packages/src/nuget_http.rs

616 lines32,079 bytesCodeBlameRaw
1//! The NuGet feed: `g1t.sh/-/nuget/<workspace>/v3/index.json`, a v3 feed
2//! for each workspace. `dotnet nuget push` sends a g1t token as its API key
3//! (`X-NuGet-ApiKey`); restores send Basic credentials (any username, a g1t
4//! token as the password) from `nuget.config`, after the feed answers a
5//! private request with a `401`.
6//!
7//! A `.nupkg` is stored once, by its SHA-256, with its `.nuspec` beside it;
8//! the flat container, registration and search documents are made from the
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. 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>`.
17
18use g1t_contracts::User;
19use g1t_contracts::events::PackageEvent;
20use g1t_contracts::new_id;
21use g1t_kit::now_ms;
22use serde_json::{Value, json};
23use worker::{Context, Headers, Method, Request, Response, ResponseBody, Result, Url};
24
25use crate::access::Action;
26use crate::db::{NewFile, NewVersion, PackageRow, VersionRow};
27use crate::digest::Digest;
28use crate::npm;
29use crate::nuget::{self, Content, Listed, NugetRoute};
30use crate::oci::{Credentials, origin, published_by};
31use crate::store::BlobStore;
32use crate::{Caller, Packages, TargetOf, token};
33
34const NUGET: &str = "nuget";
35/// The most versions a package's documents list.
36const MAX_VERSIONS: u32 = 5000;
37/// The longest README kept for a package's page.
38const MAX_README_BYTES: usize = 1024 * 1024;
39/// The most packages one search answers with.
40const MAX_SEARCH: u32 = 100;
41const DOCS: &str = "https://docs.g1t.sh/guides/nuget/";
42const TOKENS: &str = "https://g1t.sh/settings/tokens";
43
44/// A plain-text answer, which `dotnet` prints after the status.
45fn error(status: u16, message: impl Into<String>) -> Result<Response> {
46 let mut response = Response::ok(message.into())?.with_status(status);
47 response.headers_mut().set("content-type", "text/plain; charset=utf-8")?;
48 if status == 401 {
49 response.headers_mut().set("www-authenticate", "Basic realm=\"g1t\"")?;
50 }
51 Ok(response)
52}
53
54fn sign_in() -> String {
55 format!("Sign in to use this feed: give the source a username and a g1t access token from {TOKENS} as its password. See {DOCS}")
56}
57
58fn json_response(value: &Value, head: bool) -> Result<Response> {
59 let mut response = if head { Response::empty()? } else { Response::from_json(value)? };
60 response.headers_mut().set("content-type", "application/json")?;
61 response.headers_mut().set("cache-control", "no-cache")?;
62 Ok(response)
63}
64
65impl Packages {
66 /// Answers a NuGet request.
67 pub async fn nuget(&self, request: Request, ctx: &Context) -> Result<Response> {
68 let url = request.url()?;
69 let Some((workspace, route)) = nuget::route(url.path()) else {
70 return error(404, "There is nothing at this address.");
71 };
72 match self.nuget_route(request, &url, &workspace, route, ctx).await {
73 Ok(response) => Ok(response),
74 Err(problem) => {
75 worker::console_error!("packages: nuget {}: {problem}", url.path());
76 error(500, "Something went wrong on our side. Try again in a moment.")
77 }
78 }
79 }
80
81 /// Who the request is from: the push's API key, or Basic credentials
82 /// (or a `Bearer` token) from the source's settings.
83 async fn nuget_credentials(&self, request: &Request) -> Result<Credentials> {
84 if let Some(key) = request.headers().get("x-nuget-apikey")?.map(|k| k.trim().to_owned()).filter(|k| !k.is_empty()) {
85 return Ok(match self.viewer_for("token", &key).await? {
86 Some(user) => Credentials::Viewer(Some(user)),
87 None => Credentials::Bad,
88 });
89 }
90 let Some(header) = request.headers().get("authorization")? else {
91 return Ok(Credentials::None);
92 };
93 let viewer = if let Some((username, secret)) = token::basic(&header) {
94 self.viewer_for(&username, &secret).await?
95 } else if let Some(bearer) = token::bearer(&header) {
96 self.viewer_for("token", bearer).await?
97 } else {
98 None
99 };
100 Ok(match viewer {
101 Some(user) => Credentials::Viewer(Some(user)),
102 None => Credentials::Bad,
103 })
104 }
105
106 async fn nuget_route(&self, mut request: Request, url: &Url, workspace: &str, route: NugetRoute, ctx: &Context) -> Result<Response> {
107 let method = request.method();
108 let credentials = self.nuget_credentials(&request).await?;
109 let read = matches!(method, Method::Get | Method::Head);
110 if read && let Some(refused) = self.limited(&request, &credentials, "a g1t token in the source's credentials").await? {
111 return Ok(refused);
112 }
113 let viewer = match credentials {
114 Credentials::Viewer(viewer) => viewer,
115 Credentials::None => None,
116 Credentials::Token(_) | Credentials::Bad => {
117 return error(401, format!("The token is not right, or has expired. Make an access token at {TOKENS}."));
118 }
119 };
120 let viewer = viewer.as_ref();
121 let base = format!("{}/-/nuget/{workspace}", origin(url));
122 let head = method == Method::Head;
123 match route {
124 NugetRoute::Index if read => {
125 if viewer.is_none() && self.db.has_private(workspace, NUGET).await? {
126 return error(401, sign_in());
127 }
128 json_response(&nuget::service_index(&base), head)
129 }
130 NugetRoute::Versions { id } if read => self.nuget_versions(workspace, &id, viewer, head).await,
131 NugetRoute::Content { id, version, file } if read => self.nuget_content(workspace, &id, &version, file, viewer, head, ctx).await,
132 NugetRoute::Registration { id } if read => self.nuget_registration(&base, workspace, &id, None, viewer, head).await,
133 NugetRoute::Leaf { id, version } if read => self.nuget_registration(&base, workspace, &id, Some(&version), viewer, head).await,
134 NugetRoute::Search if read => self.nuget_search(url, &base, workspace, viewer).await,
135 NugetRoute::Push if method == Method::Put => self.nuget_push(&mut request, workspace, viewer).await,
136 NugetRoute::Listing { id, version } if method == Method::Delete => self.nuget_listing(workspace, &id, &version, false, viewer).await,
137 NugetRoute::Listing { id, version } if method == Method::Post => self.nuget_listing(workspace, &id, &version, true, viewer).await,
138 NugetRoute::SymbolPush if method == Method::Put => self.nuget_symbol_push(&mut request, workspace, viewer).await,
139 NugetRoute::Symbol { file, key } if read => self.nuget_symbol(workspace, &file, &key, viewer, head).await,
140 _ => error(405, "Not a method this address takes."),
141 }
142 }
143
144 /// The package, by its id in any case, if its workspace is not deleted.
145 async fn nuget_package(&self, workspace: &str, id: &str) -> Result<Option<PackageRow>> {
146 Ok(self.db.package_any_case(workspace, NUGET, id).await?.filter(|p| !p.hidden()))
147 }
148
149 /// The answer for something not there: a `401` to someone not signed
150 /// in when the workspace has private packages, so the client sends its
151 /// credentials and a private package looks like a missing one.
152 async fn nuget_absent(&self, workspace: &str, viewer: Option<&User>) -> Result<Response> {
153 if viewer.is_none() && self.db.has_private(workspace, NUGET).await? {
154 return error(401, sign_in());
155 }
156 error(404, "Not found: no such package or version, or you cannot see it.")
157 }
158
159 async fn nuget_check(&self, viewer: Option<&User>, package: &PackageRow, action: Action) -> Result<Option<Response>> {
160 let (decision, readable) = self.check(viewer, package, action).await?;
161 if decision.allowed {
162 return Ok(None);
163 }
164 if !readable && viewer.is_none() {
165 return Ok(Some(error(401, sign_in())?));
166 }
167 if !readable {
168 return Ok(Some(self.nuget_absent(&package.workspace, viewer).await?));
169 }
170 Ok(Some(error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()))?))
171 }
172
173 /// The package and its versions, oldest first, when the viewer may read it.
174 async fn nuget_readable(&self, workspace: &str, id: &str, viewer: Option<&User>) -> Result<std::result::Result<(PackageRow, Vec<VersionRow>), Response>> {
175 let Some(package) = self.nuget_package(workspace, id).await? else {
176 return Ok(Err(self.nuget_absent(workspace, viewer).await?));
177 };
178 if let Some(refusal) = self.nuget_check(viewer, &package, Action::Pull).await? {
179 return Ok(Err(refusal));
180 }
181 let mut versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
182 if versions.is_empty() {
183 return Ok(Err(self.nuget_absent(workspace, viewer).await?));
184 }
185 versions.sort_by(|a, b| nuget::compare(&a.version, &b.version));
186 Ok(Ok((package, versions)))
187 }
188
189 /// The flat container's version list: every version, unlisted ones too.
190 async fn nuget_versions(&self, workspace: &str, id: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
191 let (_, versions) = match self.nuget_readable(workspace, id, viewer).await? {
192 Ok(found) => found,
193 Err(refused) => return Ok(refused),
194 };
195 let listed: Vec<String> = versions.iter().map(|v| v.version.to_ascii_lowercase()).collect();
196 json_response(&json!({ "versions": listed }), head)
197 }
198
199 /// A version's `.nupkg` or `.nuspec`.
200 #[allow(clippy::too_many_arguments)]
201 async fn nuget_content(&self, workspace: &str, id: &str, version: &str, file: Content, viewer: Option<&User>, head: bool, ctx: &Context) -> Result<Response> {
202 let (package, versions) = match self.nuget_readable(workspace, id, viewer).await? {
203 Ok(found) => found,
204 Err(refused) => return Ok(refused),
205 };
206 let wanted = nuget::normalize(version).unwrap_or_default().to_ascii_lowercase();
207 let Some(row) = versions.iter().find(|v| v.version.to_ascii_lowercase() == wanted) else {
208 return self.nuget_absent(workspace, viewer).await;
209 };
210 let Some(kept) = self.db.file(&row.id, file.file()).await? else {
211 return self.nuget_absent(workspace, viewer).await;
212 };
213 let Some(digest) = Digest::parse(&kept.digest) else {
214 return self.nuget_absent(workspace, viewer).await;
215 };
216 let Some(blob) = self.db.package_blob(&package.id, &digest).await? else {
217 return self.nuget_absent(workspace, viewer).await;
218 };
219 let headers = Headers::new();
220 headers.set("content-type", if file == Content::Nuspec { "application/xml" } else { "application/octet-stream" })?;
221 headers.set("content-length", &blob.size.to_string())?;
222 headers.set("cache-control", "max-age=31536000")?;
223 if head {
224 return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
225 }
226 let Some(got) = self.store.get(&blob.object_key, None).await? else {
227 return self.nuget_absent(workspace, viewer).await;
228 };
229 if file == Content::Nupkg {
230 self.count_version_download(&package.id, &row.id, ctx);
231 }
232 Ok(Response::from_body(got.body)?.with_headers(headers))
233 }
234
235 /// A package's registration index, or one version's leaf.
236 async fn nuget_registration(&self, base: &str, workspace: &str, id: &str, version: Option<&str>, viewer: Option<&User>, head: bool) -> Result<Response> {
237 let (package, versions) = match self.nuget_readable(workspace, id, viewer).await? {
238 Ok(found) => found,
239 Err(refused) => return Ok(refused),
240 };
241 let metadata: Vec<Value> = versions.iter().map(VersionRow::meta).collect();
242 let listed: Vec<Listed<'_>> = versions
243 .iter()
244 .zip(&metadata)
245 .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: row.downloads })
246 .collect();
247 match version {
248 None => json_response(&nuget::registration(base, &package.name, &listed), head),
249 Some(version) => {
250 let wanted = nuget::normalize(version).unwrap_or_default().to_ascii_lowercase();
251 let Some(one) = listed.iter().find(|v| v.version.to_ascii_lowercase() == wanted) else {
252 return self.nuget_absent(workspace, viewer).await;
253 };
254 json_response(&nuget::leaf(base, &package.name, one), head)
255 }
256 }
257 }
258
259 /// Search: the workspace's packages the viewer may see whose id or
260 /// description holds the query, with their listed versions.
261 async fn nuget_search(&self, url: &Url, base: &str, workspace: &str, viewer: Option<&User>) -> Result<Response> {
262 let query = |key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
263 let q = query("q").unwrap_or_default().trim().to_ascii_lowercase();
264 let skip = query("skip").and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
265 let take = query("take").and_then(|v| v.parse::<usize>().ok()).unwrap_or(20).min(MAX_SEARCH as usize);
266 let prerelease = query("prerelease").is_some_and(|v| v.eq_ignore_ascii_case("true"));
267 if viewer.is_none() && self.db.has_private(workspace, NUGET).await? {
268 return error(401, sign_in());
269 }
270 let packages = self.db.packages_of(workspace, NUGET, 1000).await?;
271 let versions = self.db.ecosystem_versions(workspace, NUGET, 20_000).await?;
272 let mut found = Vec::new();
273 let may = self.may_all(viewer, &packages.iter().collect::<Vec<_>>(), Action::Pull).await?;
274 for (package, may) in packages.iter().zip(may) {
275 if !may {
276 continue;
277 }
278 let rows: Vec<&VersionRow> = versions
279 .iter()
280 .filter(|v| v.package_id == package.id && (prerelease || !nuget::is_prerelease(&v.version)))
281 .collect();
282 let metadata: Vec<Value> = rows.iter().map(|v| v.meta()).collect();
283 let matches = q.is_empty()
284 || package.name.to_ascii_lowercase().contains(&q)
285 || package.description.as_deref().is_some_and(|d| d.to_ascii_lowercase().contains(&q));
286 if !matches {
287 continue;
288 }
289 let mut listed: Vec<Listed<'_>> = rows
290 .iter()
291 .zip(&metadata)
292 .map(|(row, metadata)| Listed { version: &row.version, metadata, published: &row.published_at, listed: !row.is_yanked(), downloads: row.downloads })
293 .collect();
294 listed.sort_by(|a, b| nuget::compare(a.version, b.version));
295 if let Some(mut result) = nuget::search_result(base, &package.name, &listed) {
296 result["totalDownloads"] = json!(package.downloads);
297 found.push(result);
298 }
299 }
300 let total = found.len();
301 let data: Vec<Value> = found.into_iter().skip(skip).take(take).collect();
302 json_response(&json!({ "totalHits": total, "data": data }), false)
303 }
304
305 /// The package a first push makes, linked to the repository its
306 /// `.nuspec` names on g1t, or else one named like its id.
307 async fn nuget_target(&self, workspace: &str, id: &str, repository: Option<&str>) -> Result<TargetOf> {
308 let named = repository
309 .and_then(|url| npm::repository_of(&Value::String(url.to_owned()), &self.host))
310 .filter(|(owner, _)| owner == workspace)
311 .map(|(_, repo)| repo);
312 let lower = id.to_ascii_lowercase();
313 let dashed = lower.replace('.', "-");
314 let mut repo = None;
315 for candidate in named.iter().map(String::as_str).chain([lower.as_str(), dashed.as_str()]) {
316 if let Some(found) = self.repo_by_name(workspace, candidate).await? {
317 repo = Some(found);
318 break;
319 }
320 }
321 Ok(TargetOf::unmade(workspace, id, repo.map(|r| (r.id, r.name, r.is_private))))
322 }
323
324 /// `dotnet nuget push`: a `PUT` of the `.nupkg`, in a multipart body.
325 async fn nuget_push(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
326 let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
327 let too_large = || {
328 let mb = self.max_request / 1_000_000;
329 error(413, format!("A push may be at most {mb} MB. See {DOCS}#size"))
330 };
331 if declared.is_some_and(|n| n > self.max_request) {
332 return too_large();
333 }
334 let content_type = request.headers().get("content-type")?;
335 let body = request.bytes().await?;
336 if body.len() as u64 > self.max_request {
337 return too_large();
338 }
339 if viewer.is_none() {
340 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}."));
341 }
342 let nupkg = match nuget::pushed_file(content_type.as_deref(), &body) {
343 Ok(file) => file,
344 Err(message) => return error(400, message),
345 };
346 let read = match nuget::read_package(nupkg) {
347 Ok(read) => read,
348 Err(message) => return error(400, message),
349 };
350 let spec = &read.nuspec;
351 if !nuget::valid_id(&spec.id) {
352 return error(400, format!("{} is not a valid package id: letters, digits and _, in parts joined by ., - or _.", spec.id));
353 }
354 let Some(version) = nuget::normalize(&spec.version) else {
355 return error(400, format!("{} is not a version NuGet reads.", spec.version));
356 };
357
358 let found = self.db.package_any_case(workspace, NUGET, &spec.id).await?;
359 if let Some(hidden) = found.as_ref().filter(|package| package.hidden()) {
360 return error(403, Packages::hidden_refusal(hidden));
361 }
362 if found.is_none() && self.db.workspace_hidden(workspace).await? {
363 return error(403, format!("The workspace {workspace} is deleted; nothing can be pushed to it."));
364 }
365 let mut target = match &found {
366 Some(package) => TargetOf::package(package),
367 None => self.nuget_target(workspace, &spec.id, spec.repository_url.as_deref()).await?,
368 };
369 let decision = self.decide(viewer, &mut target, Action::Push).await?;
370 if !decision.allowed {
371 let readable = found.is_none() || self.decide(viewer, &mut target, Action::Pull).await?.allowed;
372 if !readable {
373 return error(404, "Not found: no such package, or you cannot see it.");
374 }
375 return error(403, decision.reason.unwrap_or_else(|| "Not allowed.".to_owned()));
376 }
377 let caller = Caller::of(viewer);
378 let package = match found {
379 Some(package) => package,
380 None => {
381 self.make_package(
382 workspace,
383 NUGET,
384 &spec.id,
385 target.repo.as_ref().map(|(id, repo, private)| (id.as_str(), repo.as_str(), *private)),
386 &caller,
387 now_ms(),
388 )
389 .await?
390 }
391 };
392 let existing = self.db.versions(&package.id, MAX_VERSIONS).await?;
393 if let Some(taken) = existing.iter().find(|v| v.version.eq_ignore_ascii_case(&version)) {
394 return error(409, format!("{} {} is already pushed, and a version is pushed once. Bump the version.", package.name, taken.version));
395 }
396 let deleted = self.db.deleted_versions(&package.id, MAX_VERSIONS).await?;
397 if let Some(taken) = deleted.iter().find(|v| v.version.eq_ignore_ascii_case(&version))
398 && let Some(refused) = self.reserved_refusal(&package, &taken.version).await?
399 {
400 return error(409, refused);
401 }
402
403 let nupkg = nupkg.to_vec();
404 let digest = Digest::of(&nupkg);
405 let size = nupkg.len() as u64;
406 let nuspec_digest = Digest::of(&read.nuspec_bytes);
407 let nuspec_size = read.nuspec_bytes.len() as u64;
408 let files = [(digest.to_string(), size), (nuspec_digest.to_string(), nuspec_size)];
409 if let Some(refusal) = self.storage_refusal(&package, &files).await? {
410 return error(403, refusal);
411 }
412 let now = now_ms();
413 for (digest, bytes, media_type) in [(&digest, nupkg, "application/octet-stream"), (&nuspec_digest, read.nuspec_bytes.clone(), "application/xml")] {
414 let stored = match self.db.blob(digest).await? {
415 Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
416 None => false,
417 };
418 let length = bytes.len() as u64;
419 if !stored {
420 self.store.put(&digest.object_key(), bytes).await?;
421 }
422 self.db.keep_blob(&package.id, digest, length, Some(media_type), &digest.object_key(), now).await?;
423 }
424 self.db
425 .publish(
426 NewVersion {
427 id: new_id("ver", now),
428 package_id: package.id.clone(),
429 version: version.clone(),
430 digest: digest.to_string(),
431 size: size + nuspec_size,
432 metadata: nuget::stored(spec, &version).to_string(),
433 subject: None,
434 published_by: published_by(&caller),
435 files: vec![
436 NewFile { name: "nupkg".to_owned(), digest: digest.to_string(), size, media_type: Some("application/octet-stream".to_owned()) },
437 NewFile {
438 name: "nuspec".to_owned(),
439 digest: nuspec_digest.to_string(),
440 size: nuspec_size,
441 media_type: Some("application/xml".to_owned()),
442 },
443 ],
444 },
445 None,
446 now,
447 )
448 .await?;
449 // The README and description the page shows: the highest stable
450 // version's, so a pre-release does not replace them.
451 let highest = !nuget::is_prerelease(&version)
452 && existing.iter().filter(|v| !nuget::is_prerelease(&v.version)).all(|v| nuget::compare(&v.version, &version).is_lt());
453 let first = nuget::is_prerelease(&version) && existing.is_empty();
454 if highest || first {
455 let readme = read.readme.as_deref().map(str::trim).filter(|r| !r.is_empty() && r.len() <= MAX_README_BYTES);
456 let readme_digest = match readme {
457 Some(readme) => {
458 let bytes = readme.as_bytes().to_vec();
459 let digest = Digest::of(&bytes);
460 if self.db.blob(&digest).await?.is_none() {
461 self.store.put(&digest.object_key(), bytes.clone()).await?;
462 }
463 self.db.keep_blob(&package.id, &digest, bytes.len() as u64, Some("text/markdown"), &digest.object_key(), now).await?;
464 Some(digest.to_string())
465 }
466 None => None,
467 };
468 self.db.set_readme(&package.id, readme_digest.as_deref(), spec.description.as_deref(), now).await?;
469 }
470 self.db.measure(&package.workspace).await?;
471 let event = PackageEvent {
472 version: Some(version.clone()),
473 digest: Some(digest.to_string()),
474 size: Some(size),
475 ..self.event_of(&package)
476 };
477 self.announce("package.published", &package, event, &caller).await;
478 self.audit(&caller, "package.publish", &package, Some(&format!("{workspace}/{}@{version}", package.name)), None).await;
479 error(201, format!("{} {version} was pushed.", package.name))
480 }
481
482 /// `dotnet nuget push` of a `.snupkg`, which it sends after the
483 /// `.nupkg` beside it: the symbols of a version already pushed, kept
484 /// with it, and each portable PDB in it kept by its symbol server key.
485 async fn nuget_symbol_push(&self, request: &mut Request, workspace: &str, viewer: Option<&User>) -> Result<Response> {
486 let declared = request.headers().get("content-length")?.and_then(|n| n.parse::<u64>().ok());
487 let too_large = || {
488 let mb = self.max_request / 1_000_000;
489 error(413, format!("A push may be at most {mb} MB. See {DOCS}#size"))
490 };
491 if declared.is_some_and(|n| n > self.max_request) {
492 return too_large();
493 }
494 let content_type = request.headers().get("content-type")?;
495 let body = request.bytes().await?;
496 if body.len() as u64 > self.max_request {
497 return too_large();
498 }
499 if viewer.is_none() {
500 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}."));
501 }
502 let snupkg = match nuget::pushed_file(content_type.as_deref(), &body) {
503 Ok(file) => file,
504 Err(message) => return error(400, message),
505 };
506 let symbols = match nuget::read_symbols(snupkg) {
507 Ok(symbols) => symbols,
508 Err(message) => return error(400, message),
509 };
510 let spec = &symbols.nuspec;
511 let Some(version) = nuget::normalize(&spec.version) else {
512 return error(400, format!("{} is not a version NuGet reads.", spec.version));
513 };
514 let push_first = || error(404, format!("Push {} {version} before its symbols: dotnet nuget push pushes the .snupkg beside a .nupkg after it.", spec.id));
515 let Some(package) = self.nuget_package(workspace, &spec.id).await? else {
516 return push_first();
517 };
518 if let Some(refusal) = self.nuget_check(viewer, &package, Action::Push).await? {
519 return Ok(refusal);
520 }
521 let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
522 let Some(row) = versions.iter().find(|v| v.version.eq_ignore_ascii_case(&version)) else {
523 return push_first();
524 };
525 let snupkg = snupkg.to_vec();
526 let digest = Digest::of(&snupkg);
527 if let Some(kept) = self.db.file(&row.id, Content::Snupkg.file()).await? {
528 if kept.digest == digest.to_string() {
529 return error(201, format!("The symbols of {} {} were pushed.", package.name, row.version));
530 }
531 return error(409, format!("{} {} already has symbols, and a version's symbols are pushed once. Bump the version.", package.name, row.version));
532 }
533 let mut files = vec![(Content::Snupkg.file().to_owned(), digest.clone(), snupkg)];
534 for pdb in symbols.pdbs {
535 let name = nuget::symbol_file(&pdb.file, &pdb.key);
536 if files.iter().all(|(kept, _, _)| *kept != name) {
537 files.push((name, Digest::of(&pdb.bytes), pdb.bytes));
538 }
539 }
540 let sizes: Vec<(String, u64)> = files.iter().map(|(_, d, bytes)| (d.to_string(), bytes.len() as u64)).collect();
541 if let Some(refusal) = self.storage_refusal(&package, &sizes).await? {
542 return error(403, refusal);
543 }
544 let now = now_ms();
545 for (name, digest, bytes) in files {
546 let size = bytes.len() as u64;
547 let stored = match self.db.blob(&digest).await? {
548 Some(blob) => self.store.head(&blob.object_key).await?.is_some(),
549 None => false,
550 };
551 if !stored {
552 self.store.put(&digest.object_key(), bytes).await?;
553 }
554 self.db.keep_blob(&package.id, &digest, size, Some("application/octet-stream"), &digest.object_key(), now).await?;
555 let file = NewFile { name, digest: digest.to_string(), size, media_type: Some("application/octet-stream".to_owned()) };
556 self.db.put_file(&package.id, &row.id, &file, now).await?;
557 }
558 let mut metadata = row.meta();
559 if metadata.is_object() {
560 metadata["symbols"] = json!(true);
561 self.db.set_version(&row.id, &row.digest, &metadata.to_string()).await?;
562 }
563 self.db.measure(&package.workspace).await?;
564 let caller = Caller::of(viewer);
565 self.audit(&caller, "package.publish_symbols", &package, Some(&format!("{workspace}/{}@{}", package.name, row.version)), None).await;
566 error(201, format!("The symbols of {} {} were pushed.", package.name, row.version))
567 }
568
569 /// The symbol server: a PDB by its file name and key, from a package
570 /// of the workspace the viewer may read.
571 async fn nuget_symbol(&self, workspace: &str, file: &str, key: &str, viewer: Option<&User>, head: bool) -> Result<Response> {
572 for found in self.db.files_named(workspace, NUGET, &nuget::symbol_file(file, key), 10).await? {
573 let Some(package) = self.db.package_by_id(&found.package_id).await?.filter(|p| !p.hidden()) else {
574 continue;
575 };
576 if !self.check(viewer, &package, Action::Pull).await?.0.allowed {
577 continue;
578 }
579 let Some(digest) = Digest::parse(&found.digest) else { continue };
580 let Some(blob) = self.db.package_blob(&package.id, &digest).await? else { continue };
581 let headers = Headers::new();
582 headers.set("content-type", "application/octet-stream")?;
583 headers.set("content-length", &blob.size.to_string())?;
584 headers.set("cache-control", "max-age=31536000")?;
585 if head {
586 return Ok(Response::from_body(ResponseBody::Empty)?.with_headers(headers));
587 }
588 let Some(got) = self.store.get(&blob.object_key, None).await? else { continue };
589 return Ok(Response::from_body(got.body)?.with_headers(headers));
590 }
591 self.nuget_absent(workspace, viewer).await
592 }
593
594 /// `dotnet nuget delete` unlists a version; a `POST` lists it again.
595 async fn nuget_listing(&self, workspace: &str, id: &str, version: &str, listed: bool, viewer: Option<&User>) -> Result<Response> {
596 let Some(package) = self.nuget_package(workspace, id).await? else {
597 return self.nuget_absent(workspace, viewer).await;
598 };
599 if let Some(refusal) = self.nuget_check(viewer, &package, Action::Push).await? {
600 return Ok(refusal);
601 }
602 let wanted = nuget::normalize(version).unwrap_or_default();
603 let versions = self.db.versions(&package.id, MAX_VERSIONS).await?;
604 let Some(row) = versions.iter().find(|v| v.version.eq_ignore_ascii_case(&wanted)) else {
605 return error(404, format!("{} {version} is not there.", package.name));
606 };
607 if row.is_yanked() == listed {
608 self.db.set_yanked(&row.id, !listed).await?;
609 self.db.touch_package(&package.id, now_ms()).await?;
610 let caller = Caller::of(viewer);
611 let action = if listed { "package.relist" } else { "package.unlist" };
612 self.audit(&caller, action, &package, Some(&format!("{workspace}/{}@{}", package.name, row.version)), None).await;
613 }
614 Ok(Response::empty()?.with_status(if listed { 200 } else { 204 }))
615 }
616}