g1t/services/packages/src/nuget.rs

630 lines28,756 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 multipart body `dotnet nuget push` sends,
4//! and the service index, registration and search documents of the v3
5//! protocol.
6//!
7//! A version keeps what the documents need from its `.nuspec` as its
8//! metadata, made once when it is pushed. Unlisting (`dotnet nuget
9//! delete`) is the version's `yanked` column: an unlisted version is still
10//! downloaded by those who name it, but no longer searched or picked.
11
12use std::cmp::Ordering;
13
14use serde_json::{Value, json};
15
16use crate::archive;
17use crate::xml;
18
19/// The longest id nuget.org takes.
20pub const MAX_ID: usize = 100;
21/// The largest `.nuspec` or README read from a package.
22const MAX_ENTRY_BYTES: usize = 1024 * 1024;
23
24/// An id: letters, digits and `_`, in parts joined by `.`, `-` or `_`.
25pub fn valid_id(id: &str) -> bool {
26 let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
27 let bytes = id.as_bytes();
28 !id.is_empty()
29 && id.len() <= MAX_ID
30 && word(bytes[0])
31 && word(bytes[bytes.len() - 1])
32 && bytes.iter().all(|b| word(*b) || matches!(b, b'.' | b'-'))
33 && !bytes.windows(2).any(|w| matches!(w[0], b'.' | b'-') && matches!(w[1], b'.' | b'-'))
34}
35
36/// A version as NuGet reads it: up to four numbers, a pre-release label,
37/// and build metadata (which is not part of the version).
38#[derive(Clone, Debug, PartialEq, Eq)]
39struct Parsed {
40 numbers: [u64; 4],
41 release: Vec<String>,
42}
43
44fn parse(version: &str) -> Option<Parsed> {
45 let version = version.trim();
46 let core = version.split_once('+').map_or(version, |(core, build)| {
47 if build.is_empty() { "" } else { core }
48 });
49 let (numbers, release) = core.split_once('-').map_or((core, None), |(n, r)| (n, Some(r)));
50 let parts: Vec<&str> = numbers.split('.').collect();
51 if parts.is_empty() || parts.len() > 4 {
52 return None;
53 }
54 let mut out = [0u64; 4];
55 for (i, part) in parts.iter().enumerate() {
56 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) || part.len() > 18 {
57 return None;
58 }
59 out[i] = part.parse().ok()?;
60 }
61 let release = match release {
62 None => Vec::new(),
63 Some(text) => {
64 let labels: Vec<String> = text.split('.').map(str::to_owned).collect();
65 if labels.iter().any(|l| l.is_empty() || !l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) {
66 return None;
67 }
68 labels
69 }
70 };
71 Some(Parsed { numbers: out, release })
72}
73
74/// NuGet's normalized form: `1.0` is `1.0.0`, `1.0.0.0` is `1.0.0`,
75/// `01.2.3` is `1.2.3`, and `+build` is dropped. `None` for a version
76/// NuGet would not take.
77pub fn normalize(version: &str) -> Option<String> {
78 let parsed = parse(version)?;
79 let [a, b, c, d] = parsed.numbers;
80 let mut out = if d == 0 { format!("{a}.{b}.{c}") } else { format!("{a}.{b}.{c}.{d}") };
81 if !parsed.release.is_empty() {
82 out.push('-');
83 out.push_str(&parsed.release.join("."));
84 }
85 Some(out)
86}
87
88pub fn is_prerelease(version: &str) -> bool {
89 parse(version).is_some_and(|p| !p.release.is_empty())
90}
91
92/// NuGet's order: by number, then a release above its pre-releases, whose
93/// labels compare as SemVer 2 says, ignoring case.
94pub fn compare(a: &str, b: &str) -> Ordering {
95 let (Some(a), Some(b)) = (parse(a), parse(b)) else {
96 return a.cmp(b);
97 };
98 a.numbers.cmp(&b.numbers).then_with(|| match (a.release.is_empty(), b.release.is_empty()) {
99 (true, true) => Ordering::Equal,
100 (true, false) => Ordering::Greater,
101 (false, true) => Ordering::Less,
102 (false, false) => {
103 for (x, y) in a.release.iter().zip(&b.release) {
104 let order = match (x.parse::<u64>(), y.parse::<u64>()) {
105 (Ok(x), Ok(y)) => x.cmp(&y),
106 (Ok(_), Err(_)) => Ordering::Less,
107 (Err(_), Ok(_)) => Ordering::Greater,
108 (Err(_), Err(_)) => x.to_ascii_lowercase().cmp(&y.to_ascii_lowercase()),
109 };
110 if order != Ordering::Equal {
111 return order;
112 }
113 }
114 a.release.len().cmp(&b.release.len())
115 }
116 })
117}
118
119/// Which of a version's files a flat container path asks for.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum Content {
122 Nupkg,
123 Nuspec,
124}
125
126/// One of the feed's endpoints, under `/-/nuget/<workspace>/`.
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub enum NugetRoute {
129 /// `v3/index.json`: the service index.
130 Index,
131 /// `v3/flatcontainer/<id>/index.json`: every version, lowercased.
132 Versions { id: String },
133 /// `v3/flatcontainer/<id>/<version>/<id>.<version>.nupkg` or `<id>.nuspec`.
134 Content { id: String, version: String, file: Content },
135 /// `v3/registration/<id>/index.json`.
136 Registration { id: String },
137 /// `v3/registration/<id>/<version>.json`.
138 Leaf { id: String, version: String },
139 /// `v3/query`.
140 Search,
141 /// `api/v2/package`: `dotnet nuget push`.
142 Push,
143 /// `api/v2/package/<id>/<version>`: `DELETE` unlists, `POST` lists again.
144 Listing { id: String, version: String },
145}
146
147/// The workspace and endpoint a path is. Ids are checked; versions are
148/// checked by the handler, which reads them as NuGet does.
149pub fn route(path: &str) -> Option<(String, NugetRoute)> {
150 let rest = path.strip_prefix("/-/nuget/")?;
151 let (workspace, rest) = rest.split_once('/')?;
152 let workspace = workspace.to_ascii_lowercase();
153 if workspace.is_empty() {
154 return None;
155 }
156 let parts: Vec<&str> = rest.trim_end_matches('/').split('/').collect();
157 let id = |text: &str| valid_id(text).then(|| text.to_owned());
158 let route = match parts.as_slice() {
159 ["v3", "index.json"] => NugetRoute::Index,
160 ["v3", "query"] => NugetRoute::Search,
161 ["v3", "flatcontainer", name, "index.json"] => NugetRoute::Versions { id: id(name)? },
162 ["v3", "flatcontainer", name, version, file] => {
163 let lower = format!("{}.{}", name.to_ascii_lowercase(), version.to_ascii_lowercase());
164 let file = if file.eq_ignore_ascii_case(&format!("{lower}.nupkg")) {
165 Content::Nupkg
166 } else if file.eq_ignore_ascii_case(&format!("{name}.nuspec")) {
167 Content::Nuspec
168 } else {
169 return None;
170 };
171 NugetRoute::Content { id: id(name)?, version: (*version).to_owned(), file }
172 }
173 ["v3", "registration", name, "index.json"] => NugetRoute::Registration { id: id(name)? },
174 ["v3", "registration", name, leaf] => NugetRoute::Leaf { id: id(name)?, version: leaf.strip_suffix(".json")?.to_owned() },
175 ["api", "v2", "package"] => NugetRoute::Push,
176 ["api", "v2", "package", name, version] => NugetRoute::Listing { id: id(name)?, version: (*version).to_owned() },
177 _ => return None,
178 };
179 Some((workspace, route))
180}
181
182/// The `.nupkg` file in a `multipart/form-data` body, as `dotnet nuget
183/// push` sends it; a body that is not multipart is taken as the file.
184pub fn pushed_file<'a>(content_type: Option<&str>, body: &'a [u8]) -> Result<&'a [u8], String> {
185 let Some(content_type) = content_type.filter(|c| c.to_ascii_lowercase().starts_with("multipart/")) else {
186 return Ok(body);
187 };
188 let boundary = content_type
189 .split(';')
190 .filter_map(|part| part.trim().split_once('='))
191 .find(|(key, _)| key.trim().eq_ignore_ascii_case("boundary"))
192 .map(|(_, value)| value.trim().trim_matches('"'))
193 .filter(|b| !b.is_empty())
194 .ok_or("The upload names no multipart boundary.")?;
195 let find = |haystack: &[u8], needle: &[u8], from: usize| {
196 haystack.get(from..).and_then(|rest| rest.windows(needle.len()).position(|w| w == needle)).map(|at| at + from)
197 };
198 let delimiter = format!("--{boundary}");
199 let start = find(body, delimiter.as_bytes(), 0).ok_or("The upload holds no package.")?;
200 let headers_end = find(body, b"\r\n\r\n", start).ok_or("The upload holds no package.")? + 4;
201 let end = find(body, format!("\r\n{delimiter}").as_bytes(), headers_end).ok_or("The upload ends early.")?;
202 Ok(&body[headers_end..end])
203}
204
205/// A dependency group: the framework it is for (none for every one), and
206/// each dependency's id and version range.
207#[derive(Clone, Debug, PartialEq, Eq)]
208pub struct Group {
209 pub target_framework: Option<String>,
210 pub dependencies: Vec<(String, String)>,
211}
212
213/// What is read from a `.nuspec`.
214#[derive(Clone, Debug, Default, PartialEq, Eq)]
215pub struct Nuspec {
216 pub id: String,
217 pub version: String,
218 pub title: Option<String>,
219 pub description: Option<String>,
220 pub summary: Option<String>,
221 pub authors: Option<String>,
222 pub tags: Vec<String>,
223 pub project_url: Option<String>,
224 pub repository_url: Option<String>,
225 pub license_expression: Option<String>,
226 pub license_url: Option<String>,
227 pub icon_url: Option<String>,
228 pub readme: Option<String>,
229 pub require_license_acceptance: bool,
230 pub groups: Vec<Group>,
231}
232
233/// A dependency's `version` as a range: `1.0` (at least 1.0) is
234/// `[1.0, )`; an interval is kept; none is any version.
235pub fn range(version: Option<&str>) -> String {
236 match version.map(str::trim).filter(|v| !v.is_empty()) {
237 None => "(, )".to_owned(),
238 Some(v) if v.starts_with('[') || v.starts_with('(') => v.to_owned(),
239 Some(v) => format!("[{v}, )"),
240 }
241}
242
243pub fn read_nuspec(text: &str) -> Result<Nuspec, String> {
244 let root = xml::parse(text).map_err(|problem| format!("The .nuspec is not XML: {problem}"))?;
245 let metadata = root.child("metadata").ok_or("The .nuspec has no <metadata>.")?;
246 let dependency = |d: &xml::Element| d.attribute("id").map(|id| (id.to_owned(), range(d.attribute("version"))));
247 let mut groups = Vec::new();
248 if let Some(deps) = metadata.child("dependencies") {
249 let loose: Vec<_> = deps.children_named("dependency").filter_map(dependency).collect();
250 if !loose.is_empty() {
251 groups.push(Group { target_framework: None, dependencies: loose });
252 }
253 for group in deps.children_named("group") {
254 groups.push(Group {
255 target_framework: group.attribute("targetFramework").map(str::to_owned).filter(|t| !t.is_empty()),
256 dependencies: group.children_named("dependency").filter_map(dependency).collect(),
257 });
258 }
259 }
260 let license = metadata.child("license");
261 Ok(Nuspec {
262 id: metadata.child_text("id").ok_or("The .nuspec has no <id>.")?,
263 version: metadata.child_text("version").ok_or("The .nuspec has no <version>.")?,
264 title: metadata.child_text("title"),
265 description: metadata.child_text("description"),
266 summary: metadata.child_text("summary"),
267 authors: metadata.child_text("authors"),
268 tags: metadata.child_text("tags").map(|t| t.split([' ', ',', ';']).filter(|t| !t.is_empty()).map(str::to_owned).collect()).unwrap_or_default(),
269 project_url: metadata.child_text("projectUrl"),
270 repository_url: metadata.child("repository").and_then(|r| r.attribute("url")).map(str::to_owned).filter(|u| !u.is_empty()),
271 license_expression: license
272 .filter(|l| l.attribute("type") == Some("expression"))
273 .map(|l| l.text.trim().to_owned())
274 .filter(|l| !l.is_empty()),
275 license_url: metadata.child_text("licenseUrl"),
276 icon_url: metadata.child_text("iconUrl"),
277 readme: metadata.child_text("readme"),
278 require_license_acceptance: metadata.child_text("requireLicenseAcceptance").is_some_and(|v| v.eq_ignore_ascii_case("true")),
279 groups,
280 })
281}
282
283/// A package's `.nuspec` (read and as its bytes) and the README it names.
284pub struct Package {
285 pub nuspec: Nuspec,
286 pub nuspec_bytes: Vec<u8>,
287 pub readme: Option<String>,
288}
289
290/// Reads a `.nupkg`: the `.nuspec` at its root, and its README.
291pub fn read_package(nupkg: &[u8]) -> Result<Package, String> {
292 let entries = archive::zip_entries(nupkg).map_err(|_| "The package is not a .nupkg: it is not a zip.".to_owned())?;
293 let entry = entries
294 .iter()
295 .find(|e| !e.name.contains('/') && e.name.to_ascii_lowercase().ends_with(".nuspec"))
296 .ok_or("The package has no .nuspec.")?;
297 let nuspec_bytes = archive::zip_read(nupkg, entry, MAX_ENTRY_BYTES)?;
298 let text = String::from_utf8(nuspec_bytes.clone()).map_err(|_| "The .nuspec is not UTF-8.".to_owned())?;
299 let nuspec = read_nuspec(&text)?;
300 let readme = match &nuspec.readme {
301 Some(path) => {
302 let wanted = path.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase();
303 entries
304 .iter()
305 .find(|e| e.name.to_ascii_lowercase() == wanted)
306 .and_then(|e| archive::zip_read(nupkg, e, MAX_ENTRY_BYTES).ok())
307 .and_then(|bytes| String::from_utf8(bytes).ok())
308 }
309 None => None,
310 };
311 Ok(Package { nuspec, nuspec_bytes, readme })
312}
313
314/// What a version keeps from its `.nuspec`, for the feed's documents.
315pub fn stored(nuspec: &Nuspec, version: &str) -> Value {
316 json!({
317 "id": nuspec.id,
318 "version": version,
319 "title": nuspec.title,
320 "description": nuspec.description,
321 "summary": nuspec.summary,
322 "authors": nuspec.authors,
323 "tags": nuspec.tags,
324 "project_url": nuspec.project_url,
325 "license_expression": nuspec.license_expression,
326 "license_url": nuspec.license_url,
327 "icon_url": nuspec.icon_url,
328 "require_license_acceptance": nuspec.require_license_acceptance,
329 "dependency_groups": nuspec.groups.iter().map(|g| json!({
330 "target_framework": g.target_framework,
331 "dependencies": g.dependencies.iter().map(|(id, range)| json!({ "id": id, "range": range })).collect::<Vec<_>>(),
332 })).collect::<Vec<_>>(),
333 })
334}
335
336/// The service index: where the client finds each resource, under `base`
337/// (`https://g1t.sh/-/nuget/acme`).
338pub fn service_index(base: &str) -> Value {
339 let resource = |id: String, kind: &str| json!({ "@id": id, "@type": kind });
340 let registration = format!("{base}/v3/registration/");
341 let query = format!("{base}/v3/query");
342 json!({
343 "version": "3.0.0",
344 "resources": [
345 resource(format!("{base}/v3/flatcontainer/"), "PackageBaseAddress/3.0.0"),
346 resource(registration.clone(), "RegistrationsBaseUrl"),
347 resource(registration.clone(), "RegistrationsBaseUrl/3.0.0-rc"),
348 resource(registration.clone(), "RegistrationsBaseUrl/3.0.0-beta"),
349 resource(registration.clone(), "RegistrationsBaseUrl/3.4.0"),
350 resource(registration, "RegistrationsBaseUrl/3.6.0"),
351 resource(query.clone(), "SearchQueryService"),
352 resource(query.clone(), "SearchQueryService/3.0.0-rc"),
353 resource(query.clone(), "SearchQueryService/3.0.0-beta"),
354 resource(query, "SearchQueryService/3.5.0"),
355 resource(format!("{base}/api/v2/package"), "PackagePublish/2.0.0"),
356 ],
357 })
358}
359
360/// One version as the registration and search documents list it.
361pub struct Listed<'a> {
362 pub version: &'a str,
363 pub metadata: &'a Value,
364 pub published: &'a str,
365 pub listed: bool,
366 pub downloads: u64,
367}
368
369/// The addresses of a version's documents and files.
370pub struct Addresses {
371 pub registration: String,
372 pub leaf: String,
373 pub content: String,
374}
375
376pub fn addresses(base: &str, id: &str, version: &str) -> Addresses {
377 let (id, version) = (id.to_ascii_lowercase(), version.to_ascii_lowercase());
378 Addresses {
379 registration: format!("{base}/v3/registration/{id}/index.json"),
380 leaf: format!("{base}/v3/registration/{id}/{version}.json"),
381 content: format!("{base}/v3/flatcontainer/{id}/{version}/{id}.{version}.nupkg"),
382 }
383}
384
385/// A version's registration leaf, with its catalog entry inlined.
386pub fn leaf(base: &str, id: &str, listed: &Listed<'_>) -> Value {
387 let at = addresses(base, id, listed.version);
388 let m = listed.metadata;
389 let groups: Vec<Value> = m["dependency_groups"]
390 .as_array()
391 .map(|groups| {
392 groups
393 .iter()
394 .enumerate()
395 .map(|(n, g)| {
396 let deps: Vec<Value> = g["dependencies"]
397 .as_array()
398 .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())
399 .unwrap_or_default();
400 let mut group = json!({ "@id": format!("{}#dependencygroup/{n}", at.leaf), "dependencies": deps });
401 if let Some(target) = g["target_framework"].as_str() {
402 group["targetFramework"] = json!(target);
403 }
404 group
405 })
406 .collect()
407 })
408 .unwrap_or_default();
409 let text = |key: &str| m[key].as_str().unwrap_or("").to_owned();
410 json!({
411 "@id": at.leaf,
412 "@type": "Package",
413 "catalogEntry": {
414 "@id": format!("{}#catalog", at.leaf),
415 "@type": "PackageDetails",
416 "id": m["id"].as_str().unwrap_or(id),
417 "version": listed.version,
418 "title": text("title"),
419 "description": text("description"),
420 "summary": text("summary"),
421 "authors": text("authors"),
422 "tags": m["tags"].as_array().cloned().unwrap_or_default(),
423 "projectUrl": text("project_url"),
424 "licenseExpression": text("license_expression"),
425 "licenseUrl": text("license_url"),
426 "iconUrl": text("icon_url"),
427 "requireLicenseAcceptance": m["require_license_acceptance"].as_bool().unwrap_or(false),
428 "dependencyGroups": groups,
429 "listed": listed.listed,
430 "published": listed.published,
431 "packageContent": at.content,
432 },
433 "packageContent": at.content,
434 "registration": at.registration,
435 })
436}
437
438/// The registration index: every version, oldest first, in one page.
439pub fn registration(base: &str, id: &str, versions: &[Listed<'_>]) -> Value {
440 let index = format!("{base}/v3/registration/{}/index.json", id.to_ascii_lowercase());
441 let (lower, upper) = (versions.first().map_or("", |v| v.version), versions.last().map_or("", |v| v.version));
442 json!({
443 "@id": index,
444 "count": 1,
445 "items": [{
446 "@id": format!("{index}#page/{lower}/{upper}"),
447 "count": versions.len(),
448 "lower": lower,
449 "upper": upper,
450 "items": versions.iter().map(|v| leaf(base, id, v)).collect::<Vec<_>>(),
451 }],
452 })
453}
454
455/// One package as search answers it: its listed versions, the newest as
456/// its version. `None` when none is listed.
457pub fn search_result(base: &str, id: &str, versions: &[Listed<'_>]) -> Option<Value> {
458 let shown: Vec<&Listed<'_>> = versions.iter().filter(|v| v.listed).collect();
459 let newest = shown.iter().max_by(|a, b| compare(a.version, b.version))?;
460 let m = newest.metadata;
461 let at = addresses(base, id, newest.version);
462 let authors: Vec<&str> = m["authors"].as_str().map(|a| a.split(',').map(str::trim).filter(|a| !a.is_empty()).collect()).unwrap_or_default();
463 Some(json!({
464 "@id": at.registration,
465 "@type": "Package",
466 "registration": at.registration,
467 "id": m["id"].as_str().unwrap_or(id),
468 "version": newest.version,
469 "description": m["description"].as_str().unwrap_or(""),
470 "summary": m["summary"].as_str().unwrap_or(""),
471 "title": m["title"].as_str().unwrap_or(""),
472 "projectUrl": m["project_url"].as_str().unwrap_or(""),
473 "licenseUrl": m["license_url"].as_str().unwrap_or(""),
474 "iconUrl": m["icon_url"].as_str().unwrap_or(""),
475 "authors": authors,
476 "tags": m["tags"].as_array().cloned().unwrap_or_default(),
477 "totalDownloads": shown.iter().map(|v| v.downloads).sum::<u64>(),
478 "verified": false,
479 "packageTypes": [{ "name": "Dependency" }],
480 "versions": shown.iter().map(|v| json!({
481 "version": v.version,
482 "downloads": v.downloads,
483 "@id": addresses(base, id, v.version).leaf,
484 })).collect::<Vec<_>>(),
485 }))
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn ids_follow_nugets_rules() {
494 for good in ["Acme.Web", "Newtonsoft.Json", "a", "my-lib_2", &"a".repeat(100)] {
495 assert!(valid_id(good), "{good}");
496 }
497 for bad in ["", ".a", "a.", "a..b", "a b", "a/b", "a.-b", &"a".repeat(101)] {
498 assert!(!valid_id(bad), "{bad}");
499 }
500 }
501
502 #[test]
503 fn versions_normalize_and_order_as_nuget_does() {
504 assert_eq!(normalize("1.0").as_deref(), Some("1.0.0"));
505 assert_eq!(normalize("1.0.0.0").as_deref(), Some("1.0.0"));
506 assert_eq!(normalize("1.0.0.4").as_deref(), Some("1.0.0.4"));
507 assert_eq!(normalize("01.02.3").as_deref(), Some("1.2.3"));
508 assert_eq!(normalize("1.0.0-Beta.1+sha.abc").as_deref(), Some("1.0.0-Beta.1"));
509 for bad in ["", "a.b", "1.0.0.0.0", "1..0", "1.0-", "1.0-a..b", "1.0+"] {
510 assert_eq!(normalize(bad), None, "{bad}");
511 }
512 assert!(is_prerelease("1.0.0-rc.1") && !is_prerelease("1.0.0"));
513 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"];
514 versions.sort_by(|a, b| compare(a, b));
515 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"]);
516 }
517
518 #[test]
519 fn every_endpoint_is_routed() {
520 let at = |route: NugetRoute| Some(("acme".to_owned(), route));
521 assert_eq!(route("/-/nuget/Acme/v3/index.json"), at(NugetRoute::Index));
522 assert_eq!(route("/-/nuget/acme/v3/query"), at(NugetRoute::Search));
523 assert_eq!(route("/-/nuget/acme/v3/flatcontainer/acme.web/index.json"), at(NugetRoute::Versions { id: "acme.web".into() }));
524 assert_eq!(
525 route("/-/nuget/acme/v3/flatcontainer/acme.web/1.0.0/acme.web.1.0.0.nupkg"),
526 at(NugetRoute::Content { id: "acme.web".into(), version: "1.0.0".into(), file: Content::Nupkg })
527 );
528 assert_eq!(
529 route("/-/nuget/acme/v3/flatcontainer/acme.web/1.0.0/acme.web.nuspec"),
530 at(NugetRoute::Content { id: "acme.web".into(), version: "1.0.0".into(), file: Content::Nuspec })
531 );
532 assert_eq!(route("/-/nuget/acme/v3/flatcontainer/acme.web/1.0.0/other.1.0.0.nupkg"), None);
533 assert_eq!(route("/-/nuget/acme/v3/registration/acme.web/index.json"), at(NugetRoute::Registration { id: "acme.web".into() }));
534 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() }));
535 assert_eq!(route("/-/nuget/acme/api/v2/package"), at(NugetRoute::Push));
536 assert_eq!(route("/-/nuget/acme/api/v2/package/"), at(NugetRoute::Push));
537 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 assert_eq!(route("/-/nuget/acme/v3/flatcontainer/a..b/index.json"), None);
539 assert_eq!(route("/-/nuget/acme"), None);
540 assert_eq!(route("/-/nuget/acme/v2"), None);
541 }
542
543 #[test]
544 fn the_push_body_is_multipart_with_the_package() {
545 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";
546 assert_eq!(pushed_file(Some("multipart/form-data; boundary=\"abc123\""), body).unwrap(), b"PK\x03\x04data\r\n--abc\r\nmore");
547 assert_eq!(pushed_file(Some("application/octet-stream"), b"PK raw").unwrap(), b"PK raw");
548 assert_eq!(pushed_file(None, b"PK raw").unwrap(), b"PK raw");
549 assert!(pushed_file(Some("multipart/form-data"), body).is_err(), "no boundary");
550 assert!(pushed_file(Some("multipart/form-data; boundary=zzz"), body).is_err());
551 }
552
553 const NUSPEC: &str = r#"<?xml version="1.0" encoding="utf-8"?>
554<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
555 <metadata>
556 <id>Acme.Web</id>
557 <version>1.2.0</version>
558 <authors>Ada, Bo</authors>
559 <description>The web client.</description>
560 <license type="expression">MIT</license>
561 <readme>docs\README.md</readme>
562 <repository type="git" url="https://g1t.sh/acme/web.git" />
563 <tags>http client</tags>
564 <dependencies>
565 <group targetFramework="net8.0">
566 <dependency id="Newtonsoft.Json" version="13.0.1" exclude="Build,Analyzers" />
567 <dependency id="Acme.Core" version="[1.0.0, 2.0.0)" />
568 </group>
569 <group targetFramework=".NETStandard2.0" />
570 </dependencies>
571 </metadata>
572</package>"#;
573
574 #[test]
575 fn a_package_is_read_from_its_nuspec() {
576 let nupkg = crate::composer::zip(&[
577 ("Acme.Web.nuspec".to_owned(), NUSPEC.as_bytes().to_vec()),
578 ("docs/README.md".to_owned(), b"# Acme.Web\n".to_vec()),
579 ("lib/net8.0/Acme.Web.dll".to_owned(), b"MZ".to_vec()),
580 ]);
581 let package = read_package(&nupkg).unwrap();
582 let spec = &package.nuspec;
583 assert_eq!((spec.id.as_str(), spec.version.as_str()), ("Acme.Web", "1.2.0"));
584 assert_eq!(spec.license_expression.as_deref(), Some("MIT"));
585 assert_eq!(spec.repository_url.as_deref(), Some("https://g1t.sh/acme/web.git"));
586 assert_eq!(spec.tags, ["http", "client"]);
587 assert_eq!(package.readme.as_deref(), Some("# Acme.Web\n"));
588 assert_eq!(spec.groups.len(), 2);
589 assert_eq!(spec.groups[0].target_framework.as_deref(), Some("net8.0"));
590 assert_eq!(spec.groups[0].dependencies, [("Newtonsoft.Json".into(), "[13.0.1, )".into()), ("Acme.Core".into(), "[1.0.0, 2.0.0)".into())]);
591 assert!(spec.groups[1].dependencies.is_empty());
592 assert!(read_package(b"not a zip").is_err());
593 let empty = crate::composer::zip(&[("lib/a.dll".to_owned(), b"MZ".to_vec())]);
594 assert!(read_package(&empty).is_err(), "no .nuspec");
595 assert_eq!(range(None), "(, )");
596 }
597
598 #[test]
599 fn the_documents_are_nugets_shape() {
600 let index = service_index("https://g1t.sh/-/nuget/acme");
601 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"] {
603 assert!(kinds.contains(&kind), "{kind}");
604 }
605 let spec = read_nuspec(NUSPEC).unwrap();
606 let (one, two) = (stored(&spec, "1.0.0"), stored(&spec, "1.2.0"));
607 let versions = [
608 Listed { version: "1.0.0", metadata: &one, published: "2026-10-01T00:00:00.000Z", listed: false, downloads: 3 },
609 Listed { version: "1.2.0", metadata: &two, published: "2026-10-06T00:00:00.000Z", listed: true, downloads: 4 },
610 ];
611 let base = "https://g1t.sh/-/nuget/acme";
612 let reg = registration(base, "Acme.Web", &versions);
613 let page = &reg["items"][0];
614 assert_eq!(page["lower"], "1.0.0");
615 assert_eq!(page["upper"], "1.2.0");
616 let entry = &page["items"][1]["catalogEntry"];
617 assert_eq!(entry["id"], "Acme.Web");
618 assert_eq!(entry["listed"], true);
619 assert_eq!(entry["packageContent"], "https://g1t.sh/-/nuget/acme/v3/flatcontainer/acme.web/1.2.0/acme.web.1.2.0.nupkg");
620 assert_eq!(entry["dependencyGroups"][0]["targetFramework"], "net8.0");
621 assert_eq!(entry["dependencyGroups"][0]["dependencies"][1]["range"], "[1.0.0, 2.0.0)");
622 assert_eq!(page["items"][0]["catalogEntry"]["listed"], false);
623 let found = search_result(base, "Acme.Web", &versions).unwrap();
624 assert_eq!(found["version"], "1.2.0");
625 assert_eq!(found["versions"].as_array().unwrap().len(), 1, "unlisted versions are not searched");
626 assert_eq!(found["totalDownloads"], 4);
627 assert_eq!(found["authors"], json!(["Ada", "Bo"]));
628 assert!(search_result(base, "Acme.Web", &versions[..1]).is_none());
629 }
630}