moon_purl

    Package URL toolkit with CycloneDX/SPDX import, canonicalization, inventory policy, and cross-format diff

    package-url
    purl
    sbom
    supply-chain
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    9 days ago
    Downloads
    5

    #Moon PURL

    CI

    Moon PURL is a pure MoonBit supply-chain component for importing CycloneDX and SPDX JSON, parsing and canonicalizing Package URLs, auditing inventories, and comparing identities across SBOM formats. It can be embedded directly in SBOM upload gates, format-migration checks, CI inventory policy, and vulnerability-database preparation pipelines.

    The first release deliberately implements a documented ASCII interoperability profile rather than claiming every Unicode rule in the full PURL specification. It accepts generic types and adds conservative checks for PyPI, npm, Maven, Go, and GitHub coordinates.

    #What it does

    • parses PURLs into typed fields with stable error codes and UTF-16 offsets;
    • imports top-level and nested CycloneDX components from JSON;
    • imports SPDX PACKAGE-MANAGER PURL external references from JSON;
    • produces a finding for every component/package and isolates bad or missing PURLs;
    • runs inventory policy directly on imported documents;
    • compares CycloneDX and SPDX snapshots through normalized identities;
    • canonicalizes scheme/type, percent escapes, PyPI names, npm names/scopes, and sorted qualifier keys;
    • matches explicit type/namespace/name/version fields and qualifier subsets;
    • creates a package-version identity suitable for joins and duplicate warnings;
    • isolates malformed rows in batch diagnostics;
    • audits inventories for invalid, unversioned, duplicate, and policy-blocked rows;
    • compares two inventories and reports added, removed, and unchanged identities;
    • ships a runnable CycloneDX integration, 1,500-component JSON test, combined 20,000-row/5,000-component workload, and four-target CI.

    #Quick start

    ///|
    test {
    let value = @moon_purl.parse(
    "pkg:npm/%40Acme/Widget@2.1.0?os=linux&arch=arm64",
    )
    assert_eq(
    value.to_string(),
    "pkg:npm/%40acme/widget@2.1.0?arch=arm64&os=linux",
    )
    assert_eq(value.identity(), "pkg:npm/%40acme/widget@2.1.0")
    }

    Install the published package from MoonCakes:

    moon add forey217/moon_purl@0.2.0

    Run the checked-in scenario and workload from this repository:

    moon run cmd/main --target js moon run cmd/benchmark --target js

    The runnable example parses an actual CycloneDX JSON document, canonicalizes three PURLs, reports one malformed PURL and one component without a PURL, then audits the imported inventory. The workload has two fixed result lines:

    rows=20000 ready=16000 duplicate=2000 invalid=1000 unversioned=1000 blocked=0 cyclonedx=5000 imported=4500 missing=250 invalid-purl=250 ready=4000 duplicate=500

    #Inventory audit

    let report = @moon_purl.audit_inventory(
    [
    { record_id: "a", purl: "pkg:pypi/Requests@2.32.3" },
    { record_id: "b", purl: "pkg:pypi/requests@2.32.3#vendor" },
    { record_id: "c", purl: "pkg:npm/lodash" },
    ],
    )

    assert_eq(report.ready, 1)
    assert_eq(report.duplicate, 1)
    assert_eq(report.unversioned, 1)

    Duplicate compares normalized type, namespace, name, and version. Qualifiers and subpath are intentionally ignored, so it is a join warning—not proof that two artifacts are byte-identical. Use hashes when byte identity matters.

    #Validation

    moon fmt --check moon check --target js --deny-warn moon test --target js --deny-warn

    There are 21 deterministic test blocks, including 1,200-row inventory and 1,500-component CycloneDX JSON scenarios. GitHub Actions checks wasm, wasm-gc, JavaScript, and native targets. See scenario validation for commands, expected counts, timings, environment, and interpretation.

    #Explicit boundaries

    • No network access, registry lookup, vulnerability query, dependency graph solver, or full CycloneDX/SPDX schema validator.
    • The SBOM adapters intentionally read only identifiers required for PURL inventory work; unrelated document fields remain the caller's responsibility.
    • No claim of full Unicode normalization; non-ASCII text is preserved under the documented profile rather than silently rewritten.
    • No claim that matching identities imply the same artifact bytes.
    • No dependency resolution, license decision, or security verdict.
    • PURL profile rules beyond the named ecosystems use the generic grammar.

    #License

    Apache-2.0.

    PurlError

    pub(all) suberror PurlError {
    Syntax(String, Int)
    Unsupported(String, Int)
    } derive(Eq,
    Debug
    )

    Stable parser or policy error. Offsets are zero-based UTF-16 positions in the original input. Error values never echo the input string.

    InventoryDecision

    pub(all) enum InventoryDecision {
    Ready
    Invalid
    Unversioned
    Duplicate
    PolicyBlocked
    } derive(Eq,
    Debug
    )

    InventoryDecision::to_string

    fn InventoryDecision::to_string(self : InventoryDecision) -> String

    InventoryDelta

    pub(all) struct InventoryDelta {
    added : Array[String]
    removed : Array[String]
    unchanged : Array[String]
    invalid_before : Int
    invalid_after : Int
    } derive(Eq,
    Debug
    )

    Identity-level change set between two dependency snapshots. Duplicate rows collapse to one identity; invalid rows are counted but never compared.

    InventoryEntry

    pub(all) struct InventoryEntry {
    record_id : String
    purl : String
    } derive(Eq,
    Debug
    )

    InventoryFinding

    pub(all) struct InventoryFinding {
    record_id : String
    input : String
    canonical : String
    decision : InventoryDecision
    code : String
    offset : Int
    } derive(Eq,
    Debug
    )

    InventoryPolicy

    pub(all) struct InventoryPolicy {
    require_version : Bool
    blocked_types : Array[String]
    required_qualifiers : Array[Qualifier]
    } derive(Eq,
    Debug
    )

    InventoryPolicy::default

    InventoryReport

    pub(all) struct InventoryReport {
    findings : Array[InventoryFinding]
    ready : Int
    invalid : Int
    unversioned : Int
    duplicate : Int
    policy_blocked : Int
    } derive(Eq,
    Debug
    )

    MatchResult

    pub(all) enum MatchResult {
    Match
    TypeMismatch
    NamespaceMismatch
    NameMismatch
    VersionMismatch
    QualifierMismatch(String)
    } derive(Eq,
    Debug
    )

    PackageUrl

    pub(all) struct PackageUrl {
    package_type : String
    namespace_parts : Array[String]
    name : String
    version : String?
    qualifiers : Array[Qualifier]
    subpath : Array[String]
    } derive(Eq,
    Debug
    )

    Package URL fields after decoding and profile normalization. Namespace is represented as path segments so encoded slashes cannot be confused with structural separators.

    PackageUrl::identity

    fn PackageUrl::identity(self : PackageUrl) -> String

    Identity used for vulnerability joins and deduplication. Qualifiers and subpath are intentionally excluded; the result retains type, namespace, name and version. This is not a statement that two artifacts are identical.

    PackageUrl::qualifier

    fn PackageUrl::qualifier(self : PackageUrl, key : String) -> String?

    PackageUrl::to_string

    fn PackageUrl::to_string(self : PackageUrl) -> String

    Return canonical spelling for the supported profile: lowercase scheme/type, normalized type-specific name, sorted lowercase qualifier keys and uppercase percent triplets for ASCII reserved data.

    ParseFinding

    pub(all) struct ParseFinding {
    index : Int
    input : String
    canonical : String
    valid : Bool
    code : String
    offset : Int
    } derive(Eq,
    Debug
    )

    ParseReport

    pub(all) struct ParseReport {
    findings : Array[ParseFinding]
    valid : Int
    invalid : Int
    } derive(Eq,
    Debug
    )

    PurlPattern

    pub(all) struct PurlPattern {
    package_type : String?
    namespace_parts : Array[String]?
    name : String?
    version : String?
    qualifiers : Array[Qualifier]
    } derive(Eq,
    Debug
    )

    PurlPattern::any

    Qualifier

    pub(all) struct Qualifier {
    key : String
    value : String
    } derive(Eq,
    Debug
    )

    SbomAuditReport

    pub(all) struct SbomAuditReport {
    import_report : SbomImportReport
    inventory_report : InventoryReport
    } derive(Eq,
    Debug
    )

    SbomFormat

    pub(all) enum SbomFormat {
    CycloneDx
    Spdx
    } derive(Eq,
    Debug
    )

    SbomImportDecision

    pub(all) enum SbomImportDecision {
    Imported
    MissingPurl
    InvalidPurl
    } derive(Eq,
    Debug
    )

    SbomImportFinding

    pub(all) struct SbomImportFinding {
    record_id : String
    decision : SbomImportDecision
    input : String
    canonical : String
    code : String
    offset : Int
    } derive(Eq,
    Debug
    )

    SbomImportReport

    pub(all) struct SbomImportReport {
    format : SbomFormat
    entries : Array[InventoryEntry]
    findings : Array[SbomImportFinding]
    imported : Int
    missing : Int
    invalid : Int
    } derive(Eq,
    Debug
    )

    audit_cyclonedx_json

    fn audit_cyclonedx_json(input : String, policy? : InventoryPolicy) -> SbomAuditReport raise PurlError

    Import and apply inventory policy in one downstream-facing operation.

    audit_inventory

    fn audit_inventory(entries : Array[InventoryEntry], policy? : InventoryPolicy) -> InventoryReport

    Audit a structured PURL inventory without network access. Every input row produces one finding. Duplicate means the same type/namespace/name/version appeared earlier; qualifiers and subpath are excluded from that key, so the result is a join warning rather than proof of byte-identical artifacts.

    audit_spdx_json

    fn audit_spdx_json(input : String, policy? : InventoryPolicy) -> SbomAuditReport raise PurlError

    Import and apply inventory policy in one downstream-facing operation.

    canonicalize

    fn canonicalize(input : String) -> String raise PurlError

    diagnose_batch

    fn diagnose_batch(inputs : Array[String]) -> ParseReport

    Parse a batch without allowing one malformed row to abort the remaining input. Findings retain source order and carry stable error codes/offsets.

    diff_inventory

    fn diff_inventory(before : Array[InventoryEntry], after : Array[InventoryEntry]) -> InventoryDelta

    Compare two inventories using normalized package-version identity. Artifact qualifiers and subpaths are excluded, matching vulnerability-database join behavior. Results are sorted so CI output is independent of input ordering.

    diff_sbom_imports

    fn diff_sbom_imports(before : SbomImportReport, after : SbomImportReport) -> InventoryDelta

    Compare any two imported SBOM documents through canonical package-version identities. This supports format migration checks such as CycloneDX to SPDX.

    import_cyclonedx_json

    fn import_cyclonedx_json(input : String) -> SbomImportReport raise PurlError

    Import top-level and nested CycloneDX components from a JSON BOM. Every component produces a finding; malformed PURLs do not abort sibling rows.

    import_spdx_json

    fn import_spdx_json(input : String) -> SbomImportReport raise PurlError

    Import Package URL external references from SPDX JSON packages.

    match_purl

    fn match_purl(value : PackageUrl, pattern : PurlPattern) -> MatchResult

    Match a parsed Package URL against explicit fields. Missing pattern fields are wildcards; present fields are exact after profile normalization. Qualifier requirements are a subset match and subpath is intentionally ignored. The first mismatch is returned for stable CI diagnostics.

    matches

    fn matches(value : PackageUrl, pattern : PurlPattern) -> Bool

    parse

    fn parse(input : String) -> PackageUrl raise PurlError

    Parse the ASCII interoperability profile of the Package URL specification. Structural separators must be literal and data separators percent-encoded. Known ecosystems receive conservative profile checks; unknown valid types remain usable through the generic grammar.

    same_package_version

    fn same_package_version(left : PackageUrl, right : PackageUrl) -> Bool

    Join two PURLs on type/namespace/name/version while deliberately ignoring qualifiers and subpath. Callers must still compare artifact digests when they need byte identity.