Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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 &amp; a half &lt;really&gt;</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"));
    }
}