server/repository/znippy-nuget/src/nuspec.rs
//! **The `.nuspec` inside a `.nupkg`** — the manifest that turns a flat
//! container of files into a dependency graph a client can resolve.
//!
//! A `.nupkg` is a zip. At its root sits exactly one `<id>.nuspec`, an XML
//! document whose `<metadata>` carries the package's identity, its description
//! and — the part that matters — its `<dependencies>`, grouped by target
//! framework. Without it a repository can serve bytes at an exact version and
//! nothing more: `dotnet restore` of a project with a dependency graph or a
//! version RANGE has nowhere to look. `registration.rs` builds that lookup, and
//! this module is where its facts come from.
//!
//! ## Why the XML is parsed by hand
//!
//! The same reason `znippy-helm` parses `Chart.yaml` by hand and `znippy-maven`
//! emits `maven-metadata.xml` by hand: the shape is small, fixed and known, and
//! an airgap product pays for every dependency twice — once in the bundle and
//! once in the audit. The scanner below is a ~130-line tokenizer over a
//! document we only ever read a dozen known element names out of. It is not a
//! general XML parser and does not pretend to be: no DTDs (and so no billion
//! laughs), no namespaces beyond stripping a prefix, no entity definitions
//! beyond the five predefined ones and numeric character references.
//!
//! ## What is deliberately not read
//!
//! `<files>`, `<contentFiles>`, `<repository>`, `<packageTypes>`, icons. A
//! registration document has no field for them, so reading them would be
//! parsing for its own sake.
use std::collections::BTreeMap;
use anyhow::{anyhow, Result};
/// One `<dependency id= version= />` — an id and a **version range**, in
/// NuGet's interval notation (`[1.0.0, )`, `[1.0.0]`, `(1.0.0, 2.0.0)`).
///
/// The range is the whole point. A bare `1.0.0` in a `.nuspec` means "1.0.0 or
/// newer", which NuGet spells `[1.0.0, )`; [`normalize_range`] does that
/// translation once, here, so the registration document a client reads is
/// already in the notation the client's range parser expects.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dependency {
pub id: String,
pub range: String,
}
/// A `<group targetFramework="…">` of dependencies. A `.nuspec` may also list
/// dependencies ungrouped, directly under `<dependencies>`; those land in a
/// group whose `target_framework` is `None`, which is exactly how the
/// registration document spells "applies to every framework".
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyGroup {
pub target_framework: Option<String>,
pub dependencies: Vec<Dependency>,
}
/// The subset of a `.nuspec` a registration document and a search hit need.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Nuspec {
pub id: String,
pub version: String,
pub title: Option<String>,
pub authors: Option<String>,
pub description: Option<String>,
pub summary: Option<String>,
pub tags: Option<String>,
pub project_url: Option<String>,
pub license_url: Option<String>,
pub license_expression: Option<String>,
pub require_license_acceptance: bool,
pub dependency_groups: Vec<DependencyGroup>,
}
/// The conventional name of the manifest inside a `.nupkg`: a single
/// `*.nuspec` at the archive ROOT. Nested `.nuspec` files exist inside
/// `lib/`-shipped tooling packages and are NOT the manifest, so depth matters.
pub fn is_root_nuspec(name: &str) -> bool {
!name.contains('/') && !name.contains('\\') && name.to_ascii_lowercase().ends_with(".nuspec")
}
/// Translate a `.nuspec` `version` attribute into NuGet's range notation.
///
/// `""`/absent → `(, )` (any version). A bare version `1.0.0` → `[1.0.0, )`,
/// NuGet's "minimum, inclusive" — a client that read `1.0.0` literally as an
/// exact pin would refuse a perfectly good 1.0.1. Anything already bracketed
/// or parenthesised is a range already and is passed through.
pub fn normalize_range(raw: &str) -> String {
let t = raw.trim();
if t.is_empty() {
return "(, )".to_string();
}
if t.starts_with('[') || t.starts_with('(') {
return t.to_string();
}
format!("[{t}, )")
}
// ── The scanner ──────────────────────────────────────────────────────────────
#[derive(Debug)]
enum Event {
/// `<name attr="v">` or `<name/>` — `empty` is the self-closing form.
Start {
name: String,
attrs: BTreeMap<String, String>,
empty: bool,
},
End {
name: String,
},
Text(String),
}
/// Strip an XML namespace prefix: `d:dependency` → `dependency`.
fn local_name(raw: &str) -> String {
raw.rsplit(':').next().unwrap_or(raw).to_ascii_lowercase()
}
/// The five predefined entities plus numeric character references. No entity
/// DECLARATIONS are honoured, which is what keeps an expansion bomb out.
fn unescape(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
let Some(semi) = tail.find(';') else {
out.push_str(tail);
return out;
};
let ent = &tail[1..semi];
match ent {
"lt" => out.push('<'),
"gt" => out.push('>'),
"amp" => out.push('&'),
"quot" => out.push('"'),
"apos" => out.push('\''),
_ => {
let cp =
if let Some(hex) = ent.strip_prefix("#x").or_else(|| ent.strip_prefix("#X")) {
u32::from_str_radix(hex, 16).ok()
} else {
ent.strip_prefix('#').and_then(|d| d.parse::<u32>().ok())
};
match cp.and_then(char::from_u32) {
Some(c) => out.push(c),
// An entity we do not know is left verbatim rather than
// dropped: it is text, not a directive.
None => out.push_str(&tail[..=semi]),
}
}
}
rest = &tail[semi + 1..];
}
out.push_str(rest);
out
}
fn parse_attrs(raw: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let b = raw.as_bytes();
let mut i = 0usize;
while i < b.len() {
while i < b.len() && (b[i] as char).is_whitespace() {
i += 1;
}
let ks = i;
while i < b.len() && b[i] != b'=' && !(b[i] as char).is_whitespace() {
i += 1;
}
if ks == i {
break;
}
let key = local_name(&raw[ks..i]);
while i < b.len() && (b[i] as char).is_whitespace() {
i += 1;
}
if i >= b.len() || b[i] != b'=' {
continue;
}
i += 1;
while i < b.len() && (b[i] as char).is_whitespace() {
i += 1;
}
if i >= b.len() {
break;
}
let quote = b[i];
if quote != b'"' && quote != b'\'' {
break;
}
i += 1;
let vs = i;
while i < b.len() && b[i] != quote {
i += 1;
}
out.insert(key, unescape(&raw[vs..i]));
i = (i + 1).min(b.len());
}
out
}
/// Tokenize the document. Bounded by the caller: the bytes handed in already
/// came through the capped zip read, so there is no size trap left here.
fn scan(xml: &str) -> Vec<Event> {
let mut out = Vec::new();
let mut rest = xml;
loop {
let Some(lt) = rest.find('<') else {
let t = unescape(rest);
if !t.trim().is_empty() {
out.push(Event::Text(t));
}
return out;
};
if lt > 0 {
let t = unescape(&rest[..lt]);
if !t.trim().is_empty() {
out.push(Event::Text(t));
}
}
let tail = &rest[lt..];
if let Some(body) = tail.strip_prefix("<![CDATA[") {
match body.find("]]>") {
// CDATA is literal text: no unescape, that is its whole point.
Some(e) => {
out.push(Event::Text(body[..e].to_string()));
rest = &body[e + 3..];
}
None => return out,
}
continue;
}
if let Some(comment) = tail.strip_prefix("<!--") {
match comment.find("-->") {
Some(e) => rest = &comment[e + 3..],
None => return out,
}
continue;
}
// `<?xml …?>` and `<!DOCTYPE …>`: skipped, never acted on. A DOCTYPE
// is where an XML entity bomb would live, and this parser has no
// machinery to expand one even if it read it.
if tail.starts_with("<?") || tail.starts_with("<!") {
match tail.find('>') {
Some(e) => rest = &tail[e + 1..],
None => return out,
}
continue;
}
let Some(gt) = tail.find('>') else { return out };
let inner = &tail[1..gt];
rest = &tail[gt + 1..];
if let Some(name) = inner.strip_prefix('/') {
out.push(Event::End {
name: local_name(name.trim()),
});
continue;
}
let (inner, empty) = match inner.strip_suffix('/') {
Some(s) => (s, true),
None => (inner, false),
};
let inner = inner.trim();
let split = inner.find(char::is_whitespace).unwrap_or(inner.len());
let name = local_name(&inner[..split]);
let attrs = parse_attrs(&inner[split..]);
out.push(Event::Start { name, attrs, empty });
}
}
/// Parse a `.nuspec` document.
///
/// Errors only when the two fields a registration cannot be built without —
/// `<id>` and `<version>` — are missing. Everything else is optional in the
/// format and optional here.
pub fn parse_nuspec(xml: &str) -> Result<Nuspec> {
let mut stack: Vec<String> = Vec::new();
let mut fields: BTreeMap<String, String> = BTreeMap::new();
let mut groups: Vec<DependencyGroup> = Vec::new();
// Dependencies written directly under `<dependencies>`, i.e. ungrouped.
let mut ungrouped: Vec<Dependency> = Vec::new();
let mut open_group: Option<DependencyGroup> = None;
for ev in scan(xml) {
match ev {
Event::Start { name, attrs, empty } => {
let in_deps = stack.iter().any(|s| s == "dependencies");
match name.as_str() {
"group" if in_deps => {
if let Some(g) = open_group.take() {
groups.push(g);
}
open_group = Some(DependencyGroup {
target_framework: attrs
.get("targetframework")
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
dependencies: Vec::new(),
});
}
"dependency" if in_deps => {
if let Some(id) =
attrs.get("id").map(|s| s.trim()).filter(|s| !s.is_empty())
{
let dep = Dependency {
id: id.to_string(),
range: normalize_range(
attrs.get("version").map(String::as_str).unwrap_or(""),
),
};
match open_group.as_mut() {
Some(g) => g.dependencies.push(dep),
None => ungrouped.push(dep),
}
}
}
_ => {}
}
if !empty {
stack.push(name);
}
}
Event::End { name } => {
if name == "group" {
if let Some(g) = open_group.take() {
groups.push(g);
}
}
// Tolerate a mismatched close rather than abandoning the
// document: a `.nuspec` that is slightly wrong still has an id.
if let Some(pos) = stack.iter().rposition(|s| *s == name) {
stack.truncate(pos);
}
}
Event::Text(t) => {
// Only `<metadata>`'s own scalar children are collected — not
// anything nested deeper, which is what keeps a `<repository
// url=…>` or a `<files>` block from overwriting a real field.
if stack.len() >= 2 && stack[stack.len() - 2] == "metadata" && !t.trim().is_empty()
{
let key = stack[stack.len() - 1].clone();
fields.entry(key).or_insert_with(|| t.trim().to_string());
}
}
}
}
if let Some(g) = open_group.take() {
groups.push(g);
}
if !ungrouped.is_empty() {
groups.push(DependencyGroup {
target_framework: None,
dependencies: ungrouped,
});
}
let id = fields
.get("id")
.filter(|s| !s.is_empty())
.cloned()
.ok_or_else(|| anyhow!(".nuspec has no <id>"))?;
let version = fields
.get("version")
.filter(|s| !s.is_empty())
.cloned()
.ok_or_else(|| anyhow!(".nuspec for {id} has no <version>"))?;
let take = |k: &str| fields.get(k).filter(|s| !s.is_empty()).cloned();
Ok(Nuspec {
id,
version,
title: take("title"),
authors: take("authors"),
description: take("description"),
summary: take("summary"),
tags: take("tags"),
project_url: take("projecturl"),
license_url: take("licenseurl"),
// `<license type="expression">MIT</license>` — the modern form. The
// element's TEXT is the expression; the type attribute distinguishes it
// from `type="file"`, which names a file we do not serve, so a `file`
// license is simply left unset rather than reported as an expression.
license_expression: match (fields.get("license"), fields.get("licensetype")) {
(Some(v), _) if !v.is_empty() => Some(v.clone()),
_ => None,
},
require_license_acceptance: fields
.get("requirelicenseacceptance")
.map(|v| v.eq_ignore_ascii_case("true"))
.unwrap_or(false),
dependency_groups: groups,
})
}
#[cfg(test)]
mod tests {
use super::*;
const REAL: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>Vetra.Holger.Client</id>
<version>1.2.3-beta.1</version>
<title>Holger Client</title>
<authors>Vetra Automation AB</authors>
<requireLicenseAcceptance>true</requireLicenseAcceptance>
<license type="expression">MIT</license>
<projectUrl>https://example.invalid/holger</projectUrl>
<description>A client & a half <really></description>
<tags>airgap holger</tags>
<dependencies>
<group targetFramework="net8.0">
<dependency id="Newtonsoft.Json" version="13.0.1" exclude="Build,Analyzers" />
<dependency id="Ranged" version="[2.0.0, 3.0.0)" />
</group>
<group targetFramework="netstandard2.0">
<dependency id="Newtonsoft.Json" version="12.0.0" />
</group>
</dependencies>
</metadata>
</package>"#;
#[test]
fn parses_a_real_nuspec_including_the_dependency_groups() {
let n = parse_nuspec(REAL).expect("parse");
assert_eq!(n.id, "Vetra.Holger.Client");
assert_eq!(n.version, "1.2.3-beta.1");
assert_eq!(n.authors.as_deref(), Some("Vetra Automation AB"));
assert_eq!(n.license_expression.as_deref(), Some("MIT"));
assert!(n.require_license_acceptance);
// Entities are unescaped, once, and not double-unescaped.
assert_eq!(n.description.as_deref(), Some("A client & a half <really>"));
assert_eq!(n.dependency_groups.len(), 2);
assert_eq!(
n.dependency_groups[0].target_framework.as_deref(),
Some("net8.0")
);
assert_eq!(n.dependency_groups[0].dependencies.len(), 2);
// A bare version becomes a MINIMUM range, not an exact pin: this is the
// single translation that makes `dotnet restore` of a graph resolve.
assert_eq!(n.dependency_groups[0].dependencies[0].range, "[13.0.1, )");
// An explicit range survives untouched.
assert_eq!(
n.dependency_groups[0].dependencies[1].range,
"[2.0.0, 3.0.0)"
);
}
#[test]
fn ungrouped_dependencies_become_the_every_framework_group() {
let xml = r#"<package><metadata><id>A</id><version>1.0.0</version>
<dependencies>
<dependency id="B" version="1.0.0"/>
<dependency id="C"/>
</dependencies></metadata></package>"#;
let n = parse_nuspec(xml).expect("parse");
assert_eq!(n.dependency_groups.len(), 1);
assert_eq!(n.dependency_groups[0].target_framework, None);
assert_eq!(
n.dependency_groups[0].dependencies[1].range, "(, )",
"no version = any"
);
}
#[test]
fn a_nuspec_without_an_id_or_version_is_an_error_not_a_blank_package() {
assert!(
parse_nuspec("<package><metadata><version>1</version></metadata></package>").is_err()
);
assert!(parse_nuspec("<package><metadata><id>A</id></metadata></package>").is_err());
assert!(parse_nuspec("not xml at all").is_err());
}
/// A DOCTYPE is skipped whole: this parser has no entity-declaration
/// machinery, so the classic XML expansion bomb has nothing to expand.
#[test]
fn a_doctype_is_skipped_and_never_expanded() {
let xml = concat!(
"<?xml version=\"1.0\"?>",
"<!DOCTYPE package [<!ENTITY lol \"lololololol\">]>",
"<package><metadata><id>A</id><version>1.0.0</version>",
"<description>&lol;</description></metadata></package>"
);
let n = parse_nuspec(xml).expect("parse");
assert_eq!(n.id, "A");
assert_eq!(
n.description.as_deref(),
Some("&lol;"),
"left verbatim, never expanded"
);
}
#[test]
fn comments_cdata_and_namespace_prefixes_are_handled() {
let xml = r#"<x:package xmlns:x="urn:n"><x:metadata>
<!-- a comment with <angle> brackets -->
<x:id>A.B</x:id><x:version>2.0</x:version>
<x:description><![CDATA[raw & <unescaped>]]></x:description>
</x:metadata></x:package>"#;
let n = parse_nuspec(xml).expect("parse");
assert_eq!(n.id, "A.B");
assert_eq!(n.version, "2.0");
assert_eq!(n.description.as_deref(), Some("raw & <unescaped>"));
}
#[test]
fn only_a_root_nuspec_is_the_manifest() {
assert!(is_root_nuspec("Thing.nuspec"));
assert!(is_root_nuspec("Thing.NuSpec"));
assert!(!is_root_nuspec("tools/Nested.nuspec"));
assert!(!is_root_nuspec("lib/net8.0/Thing.dll"));
}
}