g1t/services/packages/src/nuget.rs

831 lines38,313 bytesCodeBlame
1//! What the NuGet feed needs that does not touch the network: package ids
2//! and NuGet's normalized versions, the feed's paths, the `.nuspec` read
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.
7//!
8//! A version keeps what the documents need from its `.nuspec` as its
9//! metadata, made once when it is pushed. Unlisting (`dotnet nuget
10//! delete`) is the version's `yanked` column: an unlisted version is still
11//! downloaded by those who name it, but no longer searched or picked.
12
13use std::cmp::Ordering;
14
15use serde_json::{Value, json};
16
17use crate::archive;
18use crate::xml;
19
20/// The longest id nuget.org takes.
21pub const MAX_ID: usize = 100;
22/// The largest `.nuspec` or README read from a package.
23const MAX_ENTRY_BYTES: usize = 1024 * 1024;
24
25/// An id: letters, digits and `_`, in parts joined by `.`, `-` or `_`.
26pub fn valid_id(id: &str) -> bool {
27 let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
28 let bytes = id.as_bytes();
29 !id.is_empty()
30 && id.len() <= MAX_ID
31 && word(bytes[0])
32 && word(bytes[bytes.len() - 1])
33 && bytes.iter().all(|b| word(*b) || matches!(b, b'.' | b'-'))
34 && !bytes.windows(2).any(|w| matches!(w[0], b'.' | b'-') && matches!(w[1], b'.' | b'-'))
35}
36
37/// A version as NuGet reads it: up to four numbers, a pre-release label,
38/// and build metadata (which is not part of the version).
39#[derive(Clone, Debug, PartialEq, Eq)]
40struct Parsed {
41 numbers: [u64; 4],
42 release: Vec<String>,
43}
44
45fn parse(version: &str) -> Option<Parsed> {
46 let version = version.trim();
47 let core = version.split_once('+').map_or(version, |(core, build)| {
48 if build.is_empty() { "" } else { core }
49 });
50 let (numbers, release) = core.split_once('-').map_or((core, None), |(n, r)| (n, Some(r)));
51 let parts: Vec<&str> = numbers.split('.').collect();
52 if parts.is_empty() || parts.len() > 4 {
53 return None;
54 }
55 let mut out = [0u64; 4];
56 for (i, part) in parts.iter().enumerate() {
57 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) || part.len() > 18 {
58 return None;
59 }
60 out[i] = part.parse().ok()?;
61 }
62 let release = match release {
63 None => Vec::new(),
64 Some(text) => {
65 let labels: Vec<String> = text.split('.').map(str::to_owned).collect();
66 if labels.iter().any(|l| l.is_empty() || !l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) {
67 return None;
68 }
69 labels
70 }
71 };
72 Some(Parsed { numbers: out, release })
73}
74
75/// NuGet's normalized form: `1.0` is `1.0.0`, `1.0.0.0` is `1.0.0`,
76/// `01.2.3` is `1.2.3`, and `+build` is dropped. `None` for a version
77/// NuGet would not take.
78pub fn normalize(version: &str) -> Option<String> {
79 let parsed = parse(version)?;
80 let [a, b, c, d] = parsed.numbers;
81 let mut out = if d == 0 { format!("{a}.{b}.{c}") } else { format!("{a}.{b}.{c}.{d}") };
82 if !parsed.release.is_empty() {
83 out.push('-');
84 out.push_str(&parsed.release.join("."));
85 }
86 Some(out)
87}
88
89pub fn is_prerelease(version: &str) -> bool {
90 parse(version).is_some_and(|p| !p.release.is_empty())
91}
92
93/// NuGet's order: by number, then a release above its pre-releases, whose
94/// labels compare as SemVer 2 says, ignoring case.
95pub fn compare(a: &str, b: &str) -> Ordering {
96 let (Some(a), Some(b)) = (parse(a), parse(b)) else {
97 return a.cmp(b);
98 };
99 a.numbers.cmp(&b.numbers).then_with(|| match (a.release.is_empty(), b.release.is_empty()) {
100 (true, true) => Ordering::Equal,
101 (true, false) => Ordering::Greater,
102 (false, true) => Ordering::Less,
103 (false, false) => {
104 for (x, y) in a.release.iter().zip(&b.release) {
105 let order = match (x.parse::<u64>(), y.parse::<u64>()) {
106 (Ok(x), Ok(y)) => x.cmp(&y),
107 (Ok(_), Err(_)) => Ordering::Less,
108 (Err(_), Ok(_)) => Ordering::Greater,
109 (Err(_), Err(_)) => x.to_ascii_lowercase().cmp(&y.to_ascii_lowercase()),
110 };
111 if order != Ordering::Equal {
112 return order;
113 }
114 }
115 a.release.len().cmp(&b.release.len())
116 }
117 })
118}
119
120/// Which of a version's files a flat container path asks for.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum Content {
123 Nupkg,
124 Nuspec,
125 /// The symbol package, when one was pushed.
126 Snupkg,
127}
128
129impl 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
140/// One of the feed's endpoints, under `/-/nuget/<workspace>/`.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub enum NugetRoute {
143 /// `v3/index.json`: the service index.
144 Index,
145 /// `v3/flatcontainer/<id>/index.json`: every version, lowercased.
146 Versions { id: String },
147 /// `v3/flatcontainer/<id>/<version>/<id>.<version>.nupkg` or `<id>.nuspec`.
148 Content { id: String, version: String, file: Content },
149 /// `v3/registration/<id>/index.json`.
150 Registration { id: String },
151 /// `v3/registration/<id>/<version>.json`.
152 Leaf { id: String, version: String },
153 /// `v3/query`.
154 Search,
155 /// `api/v2/package`: `dotnet nuget push`.
156 Push,
157 /// `api/v2/package/<id>/<version>`: `DELETE` unlists, `POST` lists again.
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 },
164}
165
166/// The workspace and endpoint a path is. Ids are checked; versions are
167/// checked by the handler, which reads them as NuGet does.
168pub fn route(path: &str) -> Option<(String, NugetRoute)> {
169 let rest = path.strip_prefix("/-/nuget/")?;
170 let (workspace, rest) = rest.split_once('/')?;
171 let workspace = workspace.to_ascii_lowercase();
172 if workspace.is_empty() {
173 return None;
174 }
175 let parts: Vec<&str> = rest.trim_end_matches('/').split('/').collect();
176 let id = |text: &str| valid_id(text).then(|| text.to_owned());
177 let route = match parts.as_slice() {
178 ["v3", "index.json"] => NugetRoute::Index,
179 ["v3", "query"] => NugetRoute::Search,
180 ["v3", "flatcontainer", name, "index.json"] => NugetRoute::Versions { id: id(name)? },
181 ["v3", "flatcontainer", name, version, file] => {
182 let lower = format!("{}.{}", name.to_ascii_lowercase(), version.to_ascii_lowercase());
183 let file = if file.eq_ignore_ascii_case(&format!("{lower}.nupkg")) {
184 Content::Nupkg
185 } else if file.eq_ignore_ascii_case(&format!("{lower}.snupkg")) {
186 Content::Snupkg
187 } else if file.eq_ignore_ascii_case(&format!("{name}.nuspec")) {
188 Content::Nuspec
189 } else {
190 return None;
191 };
192 NugetRoute::Content { id: id(name)?, version: (*version).to_owned(), file }
193 }
194 ["v3", "registration", name, "index.json"] => NugetRoute::Registration { id: id(name)? },
195 ["v3", "registration", name, leaf] => NugetRoute::Leaf { id: id(name)?, version: leaf.strip_suffix(".json")?.to_owned() },
196 ["api", "v2", "package"] => NugetRoute::Push,
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 }
202 _ => return None,
203 };
204 Some((workspace, route))
205}
206
207/// A PDB's file name, as a symbol server path holds it: no folders.
208fn 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.
216fn 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).
223pub 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.
251pub 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.
262pub 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)]
268pub 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)]
277pub struct Symbols {
278 pub nuspec: Nuspec,
279 pub pdbs: Vec<Pdb>,
280}
281
282/// The largest PDB read from a symbol package.
283const 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.
287pub 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
308/// The `.nupkg` file in a `multipart/form-data` body, as `dotnet nuget
309/// push` sends it; a body that is not multipart is taken as the file.
310pub fn pushed_file<'a>(content_type: Option<&str>, body: &'a [u8]) -> Result<&'a [u8], String> {
311 let Some(content_type) = content_type.filter(|c| c.to_ascii_lowercase().starts_with("multipart/")) else {
312 return Ok(body);
313 };
314 let boundary = content_type
315 .split(';')
316 .filter_map(|part| part.trim().split_once('='))
317 .find(|(key, _)| key.trim().eq_ignore_ascii_case("boundary"))
318 .map(|(_, value)| value.trim().trim_matches('"'))
319 .filter(|b| !b.is_empty())
320 .ok_or("The upload names no multipart boundary.")?;
321 let find = |haystack: &[u8], needle: &[u8], from: usize| {
322 haystack.get(from..).and_then(|rest| rest.windows(needle.len()).position(|w| w == needle)).map(|at| at + from)
323 };
324 let delimiter = format!("--{boundary}");
325 let start = find(body, delimiter.as_bytes(), 0).ok_or("The upload holds no package.")?;
326 let headers_end = find(body, b"\r\n\r\n", start).ok_or("The upload holds no package.")? + 4;
327 let end = find(body, format!("\r\n{delimiter}").as_bytes(), headers_end).ok_or("The upload ends early.")?;
328 Ok(&body[headers_end..end])
329}
330
331/// A dependency group: the framework it is for (none for every one), and
332/// each dependency's id and version range.
333#[derive(Clone, Debug, PartialEq, Eq)]
334pub struct Group {
335 pub target_framework: Option<String>,
336 pub dependencies: Vec<(String, String)>,
337}
338
339/// What is read from a `.nuspec`.
340#[derive(Clone, Debug, Default, PartialEq, Eq)]
341pub struct Nuspec {
342 pub id: String,
343 pub version: String,
344 pub title: Option<String>,
345 pub description: Option<String>,
346 pub summary: Option<String>,
347 pub authors: Option<String>,
348 pub tags: Vec<String>,
349 pub project_url: Option<String>,
350 pub repository_url: Option<String>,
351 pub license_expression: Option<String>,
352 pub license_url: Option<String>,
353 pub icon_url: Option<String>,
354 pub readme: Option<String>,
355 pub require_license_acceptance: bool,
356 pub groups: Vec<Group>,
357 /// `<packageTypes>`: `SymbolsPackage` for a `.snupkg`.
358 pub package_types: Vec<String>,
359}
360
361/// A dependency's `version` as a range: `1.0` (at least 1.0) is
362/// `[1.0, )`; an interval is kept; none is any version.
363pub fn range(version: Option<&str>) -> String {
364 match version.map(str::trim).filter(|v| !v.is_empty()) {
365 None => "(, )".to_owned(),
366 Some(v) if v.starts_with('[') || v.starts_with('(') => v.to_owned(),
367 Some(v) => format!("[{v}, )"),
368 }
369}
370
371pub fn read_nuspec(text: &str) -> Result<Nuspec, String> {
372 let root = xml::parse(text).map_err(|problem| format!("The .nuspec is not XML: {problem}"))?;
373 let metadata = root.child("metadata").ok_or("The .nuspec has no <metadata>.")?;
374 let dependency = |d: &xml::Element| d.attribute("id").map(|id| (id.to_owned(), range(d.attribute("version"))));
375 let mut groups = Vec::new();
376 if let Some(deps) = metadata.child("dependencies") {
377 let loose: Vec<_> = deps.children_named("dependency").filter_map(dependency).collect();
378 if !loose.is_empty() {
379 groups.push(Group { target_framework: None, dependencies: loose });
380 }
381 for group in deps.children_named("group") {
382 groups.push(Group {
383 target_framework: group.attribute("targetFramework").map(str::to_owned).filter(|t| !t.is_empty()),
384 dependencies: group.children_named("dependency").filter_map(dependency).collect(),
385 });
386 }
387 }
388 let license = metadata.child("license");
389 Ok(Nuspec {
390 id: metadata.child_text("id").ok_or("The .nuspec has no <id>.")?,
391 version: metadata.child_text("version").ok_or("The .nuspec has no <version>.")?,
392 title: metadata.child_text("title"),
393 description: metadata.child_text("description"),
394 summary: metadata.child_text("summary"),
395 authors: metadata.child_text("authors"),
396 tags: metadata.child_text("tags").map(|t| t.split([' ', ',', ';']).filter(|t| !t.is_empty()).map(str::to_owned).collect()).unwrap_or_default(),
397 project_url: metadata.child_text("projectUrl"),
398 repository_url: metadata.child("repository").and_then(|r| r.attribute("url")).map(str::to_owned).filter(|u| !u.is_empty()),
399 license_expression: license
400 .filter(|l| l.attribute("type") == Some("expression"))
401 .map(|l| l.text.trim().to_owned())
402 .filter(|l| !l.is_empty()),
403 license_url: metadata.child_text("licenseUrl"),
404 icon_url: metadata.child_text("iconUrl"),
405 readme: metadata.child_text("readme"),
406 require_license_acceptance: metadata.child_text("requireLicenseAcceptance").is_some_and(|v| v.eq_ignore_ascii_case("true")),
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(),
412 })
413}
414
415/// A package's `.nuspec` (read and as its bytes) and the README it names.
416pub struct Package {
417 pub nuspec: Nuspec,
418 pub nuspec_bytes: Vec<u8>,
419 pub readme: Option<String>,
420}
421
422/// Reads a `.nupkg`: the `.nuspec` at its root, and its README.
423pub fn read_package(nupkg: &[u8]) -> Result<Package, String> {
424 let entries = archive::zip_entries(nupkg).map_err(|_| "The package is not a .nupkg: it is not a zip.".to_owned())?;
425 let entry = entries
426 .iter()
427 .find(|e| !e.name.contains('/') && e.name.to_ascii_lowercase().ends_with(".nuspec"))
428 .ok_or("The package has no .nuspec.")?;
429 let nuspec_bytes = archive::zip_read(nupkg, entry, MAX_ENTRY_BYTES)?;
430 let text = String::from_utf8(nuspec_bytes.clone()).map_err(|_| "The .nuspec is not UTF-8.".to_owned())?;
431 let nuspec = read_nuspec(&text)?;
432 let readme = match &nuspec.readme {
433 Some(path) => {
434 let wanted = path.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase();
435 entries
436 .iter()
437 .find(|e| e.name.to_ascii_lowercase() == wanted)
438 .and_then(|e| archive::zip_read(nupkg, e, MAX_ENTRY_BYTES).ok())
439 .and_then(|bytes| String::from_utf8(bytes).ok())
440 }
441 None => None,
442 };
443 Ok(Package { nuspec, nuspec_bytes, readme })
444}
445
446/// What a version keeps from its `.nuspec`, for the feed's documents.
447pub fn stored(nuspec: &Nuspec, version: &str) -> Value {
448 json!({
449 "id": nuspec.id,
450 "version": version,
451 "title": nuspec.title,
452 "description": nuspec.description,
453 "summary": nuspec.summary,
454 "authors": nuspec.authors,
455 "tags": nuspec.tags,
456 "project_url": nuspec.project_url,
457 "license_expression": nuspec.license_expression,
458 "license_url": nuspec.license_url,
459 "icon_url": nuspec.icon_url,
460 "require_license_acceptance": nuspec.require_license_acceptance,
461 "dependency_groups": nuspec.groups.iter().map(|g| json!({
462 "target_framework": g.target_framework,
463 "dependencies": g.dependencies.iter().map(|(id, range)| json!({ "id": id, "range": range })).collect::<Vec<_>>(),
464 })).collect::<Vec<_>>(),
465 })
466}
467
468/// The service index: where the client finds each resource, under `base`
469/// (`https://g1t.sh/-/nuget/acme`).
470pub fn service_index(base: &str) -> Value {
471 let resource = |id: String, kind: &str| json!({ "@id": id, "@type": kind });
472 let registration = format!("{base}/v3/registration/");
473 let query = format!("{base}/v3/query");
474 json!({
475 "version": "3.0.0",
476 "resources": [
477 resource(format!("{base}/v3/flatcontainer/"), "PackageBaseAddress/3.0.0"),
478 resource(registration.clone(), "RegistrationsBaseUrl"),
479 resource(registration.clone(), "RegistrationsBaseUrl/3.0.0-rc"),
480 resource(registration.clone(), "RegistrationsBaseUrl/3.0.0-beta"),
481 resource(registration.clone(), "RegistrationsBaseUrl/3.4.0"),
482 resource(registration, "RegistrationsBaseUrl/3.6.0"),
483 resource(query.clone(), "SearchQueryService"),
484 resource(query.clone(), "SearchQueryService/3.0.0-rc"),
485 resource(query.clone(), "SearchQueryService/3.0.0-beta"),
486 resource(query, "SearchQueryService/3.5.0"),
487 resource(format!("{base}/api/v2/package"), "PackagePublish/2.0.0"),
488 resource(format!("{base}/api/v2/symbolpackage"), "SymbolPackagePublish/4.9.0"),
489 ],
490 })
491}
492
493/// One version as the registration and search documents list it.
494pub struct Listed<'a> {
495 pub version: &'a str,
496 pub metadata: &'a Value,
497 pub published: &'a str,
498 pub listed: bool,
499 pub downloads: u64,
500}
501
502/// The addresses of a version's documents and files.
503pub struct Addresses {
504 pub registration: String,
505 pub leaf: String,
506 pub content: String,
507}
508
509pub fn addresses(base: &str, id: &str, version: &str) -> Addresses {
510 let (id, version) = (id.to_ascii_lowercase(), version.to_ascii_lowercase());
511 Addresses {
512 registration: format!("{base}/v3/registration/{id}/index.json"),
513 leaf: format!("{base}/v3/registration/{id}/{version}.json"),
514 content: format!("{base}/v3/flatcontainer/{id}/{version}/{id}.{version}.nupkg"),
515 }
516}
517
518/// A version's registration leaf, with its catalog entry inlined.
519pub fn leaf(base: &str, id: &str, listed: &Listed<'_>) -> Value {
520 let at = addresses(base, id, listed.version);
521 let m = listed.metadata;
522 let groups: Vec<Value> = m["dependency_groups"]
523 .as_array()
524 .map(|groups| {
525 groups
526 .iter()
527 .enumerate()
528 .map(|(n, g)| {
529 let deps: Vec<Value> = g["dependencies"]
530 .as_array()
531 .map(|deps| deps.iter().map(|d| json!({ "@id": format!("{}#dependency/{n}/{}", at.leaf, d["id"].as_str().unwrap_or("")), "id": d["id"], "range": d["range"] })).collect())
532 .unwrap_or_default();
533 let mut group = json!({ "@id": format!("{}#dependencygroup/{n}", at.leaf), "dependencies": deps });
534 if let Some(target) = g["target_framework"].as_str() {
535 group["targetFramework"] = json!(target);
536 }
537 group
538 })
539 .collect()
540 })
541 .unwrap_or_default();
542 let text = |key: &str| m[key].as_str().unwrap_or("").to_owned();
543 json!({
544 "@id": at.leaf,
545 "@type": "Package",
546 "catalogEntry": {
547 "@id": format!("{}#catalog", at.leaf),
548 "@type": "PackageDetails",
549 "id": m["id"].as_str().unwrap_or(id),
550 "version": listed.version,
551 "title": text("title"),
552 "description": text("description"),
553 "summary": text("summary"),
554 "authors": text("authors"),
555 "tags": m["tags"].as_array().cloned().unwrap_or_default(),
556 "projectUrl": text("project_url"),
557 "licenseExpression": text("license_expression"),
558 "licenseUrl": text("license_url"),
559 "iconUrl": text("icon_url"),
560 "requireLicenseAcceptance": m["require_license_acceptance"].as_bool().unwrap_or(false),
561 "dependencyGroups": groups,
562 "listed": listed.listed,
563 "published": listed.published,
564 "packageContent": at.content,
565 "downloads": listed.downloads,
566 },
567 "packageContent": at.content,
568 "registration": at.registration,
569 })
570}
571
572/// The registration index: every version, oldest first, in one page.
573pub fn registration(base: &str, id: &str, versions: &[Listed<'_>]) -> Value {
574 let index = format!("{base}/v3/registration/{}/index.json", id.to_ascii_lowercase());
575 let (lower, upper) = (versions.first().map_or("", |v| v.version), versions.last().map_or("", |v| v.version));
576 json!({
577 "@id": index,
578 "count": 1,
579 "items": [{
580 "@id": format!("{index}#page/{lower}/{upper}"),
581 "count": versions.len(),
582 "lower": lower,
583 "upper": upper,
584 "items": versions.iter().map(|v| leaf(base, id, v)).collect::<Vec<_>>(),
585 }],
586 })
587}
588
589/// One package as search answers it: its listed versions, the newest as
590/// its version. `None` when none is listed.
591pub fn search_result(base: &str, id: &str, versions: &[Listed<'_>]) -> Option<Value> {
592 let shown: Vec<&Listed<'_>> = versions.iter().filter(|v| v.listed).collect();
593 let newest = shown.iter().max_by(|a, b| compare(a.version, b.version))?;
594 let m = newest.metadata;
595 let at = addresses(base, id, newest.version);
596 let authors: Vec<&str> = m["authors"].as_str().map(|a| a.split(',').map(str::trim).filter(|a| !a.is_empty()).collect()).unwrap_or_default();
597 Some(json!({
598 "@id": at.registration,
599 "@type": "Package",
600 "registration": at.registration,
601 "id": m["id"].as_str().unwrap_or(id),
602 "version": newest.version,
603 "description": m["description"].as_str().unwrap_or(""),
604 "summary": m["summary"].as_str().unwrap_or(""),
605 "title": m["title"].as_str().unwrap_or(""),
606 "projectUrl": m["project_url"].as_str().unwrap_or(""),
607 "licenseUrl": m["license_url"].as_str().unwrap_or(""),
608 "iconUrl": m["icon_url"].as_str().unwrap_or(""),
609 "authors": authors,
610 "tags": m["tags"].as_array().cloned().unwrap_or_default(),
611 "totalDownloads": shown.iter().map(|v| v.downloads).sum::<u64>(),
612 "verified": false,
613 "packageTypes": [{ "name": "Dependency" }],
614 "versions": shown.iter().map(|v| json!({
615 "version": v.version,
616 "downloads": v.downloads,
617 "@id": addresses(base, id, v.version).leaf,
618 })).collect::<Vec<_>>(),
619 }))
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 #[test]
627 fn ids_follow_nugets_rules() {
628 for good in ["Acme.Web", "Newtonsoft.Json", "a", "my-lib_2", &"a".repeat(100)] {
629 assert!(valid_id(good), "{good}");
630 }
631 for bad in ["", ".a", "a.", "a..b", "a b", "a/b", "a.-b", &"a".repeat(101)] {
632 assert!(!valid_id(bad), "{bad}");
633 }
634 }
635
636 #[test]
637 fn versions_normalize_and_order_as_nuget_does() {
638 assert_eq!(normalize("1.0").as_deref(), Some("1.0.0"));
639 assert_eq!(normalize("1.0.0.0").as_deref(), Some("1.0.0"));
640 assert_eq!(normalize("1.0.0.4").as_deref(), Some("1.0.0.4"));
641 assert_eq!(normalize("01.02.3").as_deref(), Some("1.2.3"));
642 assert_eq!(normalize("1.0.0-Beta.1+sha.abc").as_deref(), Some("1.0.0-Beta.1"));
643 for bad in ["", "a.b", "1.0.0.0.0", "1..0", "1.0-", "1.0-a..b", "1.0+"] {
644 assert_eq!(normalize(bad), None, "{bad}");
645 }
646 assert!(is_prerelease("1.0.0-rc.1") && !is_prerelease("1.0.0"));
647 let mut versions = vec!["1.0.0", "1.0.0-beta.2", "1.0.0-beta.10", "1.0.0-alpha", "0.9.0", "1.0.0.1", "1.0.0-BETA"];
648 versions.sort_by(|a, b| compare(a, b));
649 assert_eq!(versions, ["0.9.0", "1.0.0-alpha", "1.0.0-BETA", "1.0.0-beta.2", "1.0.0-beta.10", "1.0.0", "1.0.0.1"]);
650 }
651
652 #[test]
653 fn every_endpoint_is_routed() {
654 let at = |route: NugetRoute| Some(("acme".to_owned(), route));
655 assert_eq!(route("/-/nuget/Acme/v3/index.json"), at(NugetRoute::Index));
656 assert_eq!(route("/-/nuget/acme/v3/query"), at(NugetRoute::Search));
657 assert_eq!(route("/-/nuget/acme/v3/flatcontainer/acme.web/index.json"), at(NugetRoute::Versions { id: "acme.web".into() }));
658 assert_eq!(
659 route("/-/nuget/acme/v3/flatcontainer/acme.web/1.0.0/acme.web.1.0.0.nupkg"),
660 at(NugetRoute::Content { id: "acme.web".into(), version: "1.0.0".into(), file: Content::Nupkg })
661 );
662 assert_eq!(
663 route("/-/nuget/acme/v3/flatcontainer/acme.web/1.0.0/acme.web.nuspec"),
664 at(NugetRoute::Content { id: "acme.web".into(), version: "1.0.0".into(), file: Content::Nuspec })
665 );
666 assert_eq!(route("/-/nuget/acme/v3/flatcontainer/acme.web/1.0.0/other.1.0.0.nupkg"), None);
667 assert_eq!(route("/-/nuget/acme/v3/registration/acme.web/index.json"), at(NugetRoute::Registration { id: "acme.web".into() }));
668 assert_eq!(route("/-/nuget/acme/v3/registration/acme.web/1.0.0.json"), at(NugetRoute::Leaf { id: "acme.web".into(), version: "1.0.0".into() }));
669 assert_eq!(route("/-/nuget/acme/api/v2/package"), at(NugetRoute::Push));
670 assert_eq!(route("/-/nuget/acme/api/v2/package/"), at(NugetRoute::Push));
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() }));
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");
685 assert_eq!(route("/-/nuget/acme"), None);
686 assert_eq!(route("/-/nuget/acme/v2"), None);
687 }
688
689 #[test]
690 fn the_push_body_is_multipart_with_the_package() {
691 let body = b"--abc123\r\nContent-Type: application/octet-stream\r\nContent-Disposition: form-data; name=package; filename=package.nupkg\r\n\r\nPK\x03\x04data\r\n--abc\r\nmore\r\n--abc123--\r\n";
692 assert_eq!(pushed_file(Some("multipart/form-data; boundary=\"abc123\""), body).unwrap(), b"PK\x03\x04data\r\n--abc\r\nmore");
693 assert_eq!(pushed_file(Some("application/octet-stream"), b"PK raw").unwrap(), b"PK raw");
694 assert_eq!(pushed_file(None, b"PK raw").unwrap(), b"PK raw");
695 assert!(pushed_file(Some("multipart/form-data"), body).is_err(), "no boundary");
696 assert!(pushed_file(Some("multipart/form-data; boundary=zzz"), body).is_err());
697 }
698
699 const NUSPEC: &str = r#"<?xml version="1.0" encoding="utf-8"?>
700<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
701 <metadata>
702 <id>Acme.Web</id>
703 <version>1.2.0</version>
704 <authors>Ada, Bo</authors>
705 <description>The web client.</description>
706 <license type="expression">MIT</license>
707 <readme>docs\README.md</readme>
708 <repository type="git" url="https://g1t.sh/acme/web.git" />
709 <tags>http client</tags>
710 <dependencies>
711 <group targetFramework="net8.0">
712 <dependency id="Newtonsoft.Json" version="13.0.1" exclude="Build,Analyzers" />
713 <dependency id="Acme.Core" version="[1.0.0, 2.0.0)" />
714 </group>
715 <group targetFramework=".NETStandard2.0" />
716 </dependencies>
717 </metadata>
718</package>"#;
719
720 #[test]
721 fn a_package_is_read_from_its_nuspec() {
722 let nupkg = crate::composer::zip(&[
723 ("Acme.Web.nuspec".to_owned(), NUSPEC.as_bytes().to_vec()),
724 ("docs/README.md".to_owned(), b"# Acme.Web\n".to_vec()),
725 ("lib/net8.0/Acme.Web.dll".to_owned(), b"MZ".to_vec()),
726 ]);
727 let package = read_package(&nupkg).unwrap();
728 let spec = &package.nuspec;
729 assert_eq!((spec.id.as_str(), spec.version.as_str()), ("Acme.Web", "1.2.0"));
730 assert_eq!(spec.license_expression.as_deref(), Some("MIT"));
731 assert_eq!(spec.repository_url.as_deref(), Some("https://g1t.sh/acme/web.git"));
732 assert_eq!(spec.tags, ["http", "client"]);
733 assert_eq!(package.readme.as_deref(), Some("# Acme.Web\n"));
734 assert_eq!(spec.groups.len(), 2);
735 assert_eq!(spec.groups[0].target_framework.as_deref(), Some("net8.0"));
736 assert_eq!(spec.groups[0].dependencies, [("Newtonsoft.Json".into(), "[13.0.1, )".into()), ("Acme.Core".into(), "[1.0.0, 2.0.0)".into())]);
737 assert!(spec.groups[1].dependencies.is_empty());
738 assert!(read_package(b"not a zip").is_err());
739 let empty = crate::composer::zip(&[("lib/a.dll".to_owned(), b"MZ".to_vec())]);
740 assert!(read_package(&empty).is_err(), "no .nuspec");
741 assert_eq!(range(None), "(, )");
742 }
743
744 #[test]
745 fn the_documents_are_nugets_shape() {
746 let index = service_index("https://g1t.sh/-/nuget/acme");
747 let kinds: Vec<&str> = index["resources"].as_array().unwrap().iter().map(|r| r["@type"].as_str().unwrap()).collect();
748 for kind in ["PackageBaseAddress/3.0.0", "RegistrationsBaseUrl", "SearchQueryService", "PackagePublish/2.0.0", "SymbolPackagePublish/4.9.0"] {
749 assert!(kinds.contains(&kind), "{kind}");
750 }
751 let spec = read_nuspec(NUSPEC).unwrap();
752 let (one, two) = (stored(&spec, "1.0.0"), stored(&spec, "1.2.0"));
753 let versions = [
754 Listed { version: "1.0.0", metadata: &one, published: "2026-10-01T00:00:00.000Z", listed: false, downloads: 3 },
755 Listed { version: "1.2.0", metadata: &two, published: "2026-10-06T00:00:00.000Z", listed: true, downloads: 4 },
756 ];
757 let base = "https://g1t.sh/-/nuget/acme";
758 let reg = registration(base, "Acme.Web", &versions);
759 let page = &reg["items"][0];
760 assert_eq!(page["lower"], "1.0.0");
761 assert_eq!(page["upper"], "1.2.0");
762 let entry = &page["items"][1]["catalogEntry"];
763 assert_eq!(entry["id"], "Acme.Web");
764 assert_eq!(entry["listed"], true);
765 assert_eq!(entry["packageContent"], "https://g1t.sh/-/nuget/acme/v3/flatcontainer/acme.web/1.2.0/acme.web.1.2.0.nupkg");
766 assert_eq!(entry["dependencyGroups"][0]["targetFramework"], "net8.0");
767 assert_eq!(entry["dependencyGroups"][0]["dependencies"][1]["range"], "[1.0.0, 2.0.0)");
768 assert_eq!(page["items"][0]["catalogEntry"]["listed"], false);
769 assert_eq!(page["items"][0]["catalogEntry"]["downloads"], 3, "each version's own");
770 let found = search_result(base, "Acme.Web", &versions).unwrap();
771 assert_eq!(found["version"], "1.2.0");
772 assert_eq!(found["versions"].as_array().unwrap().len(), 1, "unlisted versions are not searched");
773 assert_eq!(found["totalDownloads"], 4);
774 assert_eq!(found["authors"], json!(["Ada", "Bo"]));
775 assert!(search_result(base, "Acme.Web", &versions[..1]).is_none());
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 }
831}