provenant-cli 1.0.6

Fast Rust scanner for licenses, copyrights, package metadata, SBOMs, and provenance data.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
// SPDX-FileCopyrightText: Provenant contributors
// SPDX-License-Identifier: Apache-2.0

//! Central, per-type Package-URL (PURL) normalization.
//!
//! The `packageurl` crate percent-encodes components and lowercases a few
//! hard-coded names, but does not apply the full per-type case/canonicalization
//! rules from the [purl-spec](https://github.com/package-url/purl-spec), and
//! never touches the namespace. Without a central layer each parser would have
//! to reimplement these rules, so the same package could get different PURLs
//! from different datasources (e.g. `typing_extensions` vs `typing-extensions`),
//! breaking dedup and registry/vuln-database lookups.
//!
//! Every emitted PURL passes through [`normalize_purl`]: in-memory before a
//! [`crate::models::Package`] derives its `package_uid` (the dedup key), and at
//! the `src/output_schema` boundary for package, package-data, dependency, and
//! resolved-package PURLs.
//!
//! This layer covers the case/name-canonicalization rules. Structural per-type
//! fixes that need parser knowledge (type remapping, moving a value between
//! namespace/qualifier/subpath, synthesizing a required qualifier) stay with the
//! owning parser. Unlisted types are returned unchanged, preserving the
//! case-sensitive types (npm, maven, cargo, gem, …).

use std::borrow::Cow;
use std::str::FromStr;

use packageurl::PackageUrl;

/// Normalize a PURL string according to its type's spec rules.
///
/// Per-type case/canonicalization rules are applied to the namespace and name;
/// version, qualifiers, and subpath are preserved. Unparsable input and types
/// with no rule are returned unchanged, so already-canonical PURLs never churn.
pub fn normalize_purl(purl: &str) -> String {
    let Ok(parsed) = PackageUrl::from_str(purl) else {
        return purl.to_string();
    };

    let (new_namespace, new_name): (Option<String>, String) = match parsed.ty() {
        // PEP 503: lowercase, then collapse runs of `-_.` to a single `-` (the
        // crate only handles `_`). Namespace is prohibited for pypi.
        "pypi" => (None, normalize_pypi_name(parsed.name())),

        // Lowercase namespace + name. The crate lowercases some of these names
        // but never the namespace.
        "composer" | "hex" | "github" | "gitlab" | "bitbucket" => (
            parsed.namespace().map(str::to_ascii_lowercase),
            parsed.name().to_ascii_lowercase(),
        ),

        // golang's spec is self-contradictory and acknowledged-broken; the
        // decided direction (purl-spec#308) is to lowercase only the host
        // segment and preserve path-part case (e.g. keep `github.com/Azure/…`).
        // The crate force-lowercases the whole golang namespace at parse time,
        // so `parsed` has already lost the case — edit the raw string instead.
        "golang" => return lowercase_first_path_segment(purl, "pkg:golang/"),

        _ => return purl.to_string(),
    };

    rebuild(purl, &parsed, new_namespace, new_name)
}

/// Re-emit `parsed` with a replaced namespace/name, preserving the rest.
///
/// Falls back to the original string if the rebuilt PURL cannot be constructed.
fn rebuild(
    original: &str,
    parsed: &PackageUrl<'_>,
    namespace: Option<String>,
    name: String,
) -> String {
    let Ok(mut rebuilt) = PackageUrl::new(parsed.ty().to_string(), name) else {
        return original.to_string();
    };

    if let Some(namespace) = namespace.filter(|value| !value.is_empty())
        && rebuilt.with_namespace(namespace).is_err()
    {
        return original.to_string();
    }

    if let Some(version) = parsed.version()
        && rebuilt.with_version(version.to_string()).is_err()
    {
        return original.to_string();
    }

    for (key, value) in parsed.qualifiers() {
        if rebuilt
            .add_qualifier(key.to_string(), value.to_string())
            .is_err()
        {
            return original.to_string();
        }
    }

    if let Some(subpath) = parsed.subpath()
        && rebuilt.with_subpath(subpath.to_string()).is_err()
    {
        return original.to_string();
    }

    rebuilt.to_string()
}

/// Apply the PEP 503 normalized distribution name rule: lowercase, then collapse
/// every run of `-`, `_`, or `.` into a single `-`.
fn normalize_pypi_name(name: &str) -> String {
    let lower = name.to_ascii_lowercase();
    let mut normalized = String::with_capacity(lower.len());
    let mut last_was_separator = false;

    for ch in lower.chars() {
        if matches!(ch, '-' | '_' | '.') {
            if !last_was_separator {
                normalized.push('-');
                last_was_separator = true;
            }
        } else {
            normalized.push(ch);
            last_was_separator = false;
        }
    }

    normalized
}

/// Lowercase the first path segment that follows `prefix` in a PURL string,
/// stopping at the next path separator or component delimiter (`/ @ ? #`).
///
/// Used for golang's host-only lowercasing: it edits the raw string so the
/// remaining path parts, version, qualifiers, and subpath survive untouched.
/// Returns the input unchanged if it does not start with `prefix`.
fn lowercase_first_path_segment(purl: &str, prefix: &str) -> String {
    let Some(rest) = purl.strip_prefix(prefix) else {
        return purl.to_string();
    };
    let end = rest.find(['/', '@', '?', '#']).unwrap_or(rest.len());
    format!(
        "{prefix}{}{}",
        rest[..end].to_ascii_lowercase(),
        &rest[end..]
    )
}

/// The qualifier a UID's instance marker normally uses.
const UID_MARKER: &str = "uuid";

/// The marker used when the PURL already carries a `uuid` qualifier of its own.
///
/// Julia's registry identity is exactly that — the spec requires it, and a Julia
/// name alone is ambiguous without it. Appending a second `uuid` produced a PURL
/// with a duplicate qualifier key, which a parser resolves last-wins: the
/// package's own identity was silently discarded, and re-emitting dropped it
/// from the string entirely.
const UID_MARKER_ALT: &str = "uid";

/// Add the qualifier that turns a PURL into a UID, keeping it a qualifier and
/// leaving the PURL's own qualifiers intact.
///
/// A PURL orders its parts `…?qualifiers#subpath`, so appending to the end of the
/// string lands the marker *inside the subpath* whenever one is present: a
/// cocoapods subspec UID came out as `pkg:cocoapods/SwiftFormat@0.44.17#CLI?uuid=…`,
/// where `?uuid=…` is no longer a qualifier at all. Insert ahead of the subpath
/// instead, and use `&` only when the PURL already carries a qualifier.
///
/// Non-PURL bases (the `generated-package:` fallback identity) carry neither
/// qualifiers nor a subpath, so they simply take the `?uuid=` form.
pub(crate) fn append_uuid_qualifier(base: &str, uuid: &str) -> String {
    let (head, subpath) = split_subpath(base);
    let separator = if head.contains('?') { '&' } else { '?' };
    let marker = if has_qualifier(head, UID_MARKER) {
        UID_MARKER_ALT
    } else {
        UID_MARKER
    };

    match subpath {
        Some(subpath) => format!("{head}{separator}{marker}={uuid}#{subpath}"),
        None => format!("{head}{separator}{marker}={uuid}"),
    }
}

/// The UID with its instance marker removed, restoring the underlying PURL.
///
/// Borrows whenever the UID has no subpath, which is the common case; only a
/// subpath-carrying UID needs the two remaining parts rejoined.
pub(crate) fn strip_uuid_qualifier(uid: &str) -> Cow<'_, str> {
    let (head, subpath) = split_subpath(uid);
    let Some((prefix, _)) = split_uid_marker(head) else {
        return Cow::Borrowed(uid);
    };

    match subpath {
        Some(subpath) => Cow::Owned(format!("{prefix}#{subpath}")),
        None => Cow::Borrowed(prefix),
    }
}

/// The value of a UID's instance marker, or `None` when it carries none.
pub(crate) fn uuid_qualifier_value(uid: &str) -> Option<&str> {
    let (head, _) = split_subpath(uid);
    split_uid_marker(head).map(|(_, uuid)| uuid)
}

/// Whether `head` already carries `key` as a qualifier.
///
/// Anchored on the qualifier separator so `uid` does not match inside `uuid`.
fn has_qualifier(head: &str, key: &str) -> bool {
    head.contains(&format!("?{key}=")) || head.contains(&format!("&{key}="))
}

/// Splits a UID's qualifier section into the PURL before its instance marker and
/// the marker's value.
///
/// Prefers the alternate marker, which is only ever emitted when the PURL has a
/// `uuid` of its own. Otherwise takes the *last* `uuid`, so a UID produced before
/// the alternate marker existed still resolves to the appended one rather than to
/// the package's registry identity.
fn split_uid_marker(head: &str) -> Option<(&str, &str)> {
    for marker in [UID_MARKER_ALT, UID_MARKER] {
        let separator_index = [format!("?{marker}="), format!("&{marker}=")]
            .iter()
            .filter_map(|pattern| head.rfind(pattern.as_str()))
            .max();

        if let Some(index) = separator_index {
            let value_start = index + marker.len() + 2;
            let value = &head[value_start..];
            let value = value.split_once('&').map_or(value, |(value, _)| value);
            return Some((&head[..index], value));
        }
    }
    None
}

fn split_subpath(purl: &str) -> (&str, Option<&str>) {
    match purl.split_once('#') {
        Some((head, subpath)) => (head, Some(subpath)),
        None => (purl, None),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn uuid_qualifier_stays_a_qualifier_when_the_purl_has_a_subpath() {
        let uid = append_uuid_qualifier("pkg:cocoapods/SwiftFormat@0.44.17#CLI", "abc");
        assert_eq!(uid, "pkg:cocoapods/SwiftFormat@0.44.17?uuid=abc#CLI");
        assert_eq!(
            strip_uuid_qualifier(&uid),
            "pkg:cocoapods/SwiftFormat@0.44.17#CLI"
        );

        // The parsed UID must still expose the original subpath rather than one
        // with the uuid swallowed into it.
        let parsed = PackageUrl::from_str(&uid).expect("uid should parse as a purl");
        assert_eq!(parsed.subpath(), Some("CLI"));
        assert_eq!(
            parsed.qualifiers().get("uuid").map(Cow::as_ref),
            Some("abc")
        );
    }

    #[test]
    fn uid_marker_does_not_collide_with_a_purls_own_uuid_qualifier() {
        // julia's registry identity is a `uuid` qualifier and the spec requires
        // it. A second one made the two indistinguishable: a parser resolves
        // duplicate keys last-wins, so the package's own identity was discarded
        // and re-emitting dropped it from the string.
        let base = "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3";
        let uid = append_uuid_qualifier(base, "98920f38-6039-4eaf-925e-f1216f083eba");
        assert_eq!(
            uid,
            "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3&uid=98920f38-6039-4eaf-925e-f1216f083eba"
        );

        // The registry identity survives a parse, and the marker is separate.
        let parsed = PackageUrl::from_str(&uid).expect("uid should parse");
        assert_eq!(
            parsed.qualifiers().get("uuid").map(Cow::as_ref),
            Some("cd3eb016-35fb-5094-929b-558a96fad6f3")
        );
        assert_eq!(
            parsed.qualifiers().get("uid").map(Cow::as_ref),
            Some("98920f38-6039-4eaf-925e-f1216f083eba")
        );

        // And the PURL is recoverable, so two julia packages sharing a name and
        // version no longer collapse to the same key.
        assert_eq!(strip_uuid_qualifier(&uid), base);
        assert_eq!(
            uuid_qualifier_value(&uid),
            Some("98920f38-6039-4eaf-925e-f1216f083eba")
        );
    }

    #[test]
    fn a_uid_written_before_the_alternate_marker_resolves_to_the_appended_one() {
        // Reading back output produced when both markers were spelled `uuid`:
        // the appended one is the last, so the package's own identity is what
        // survives stripping.
        let legacy = "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3&uuid=98920f38-6039-4eaf-925e-f1216f083eba";
        assert_eq!(
            strip_uuid_qualifier(legacy),
            "pkg:julia/HTTP@1.0.0?uuid=cd3eb016-35fb-5094-929b-558a96fad6f3"
        );
        assert_eq!(
            uuid_qualifier_value(legacy),
            Some("98920f38-6039-4eaf-925e-f1216f083eba")
        );
    }

    #[test]
    fn uuid_qualifier_joins_existing_qualifiers_with_an_ampersand() {
        let uid = append_uuid_qualifier("pkg:generic/x?arch=amd64", "abc");
        assert_eq!(uid, "pkg:generic/x?arch=amd64&uuid=abc");
        assert_eq!(strip_uuid_qualifier(&uid), "pkg:generic/x?arch=amd64");
    }

    #[test]
    fn uuid_qualifier_round_trips_plain_purls_and_opaque_bases() {
        for base in [
            "pkg:pypi/requests@2.0",
            "pkg:npm/%40scope/name@1.0.0",
            "generated-package:cargo/unknown@unknown",
        ] {
            let uid = append_uuid_qualifier(base, "abc");
            assert_eq!(uid, format!("{base}?uuid=abc"));
            assert_eq!(strip_uuid_qualifier(&uid), base);
        }
    }

    #[test]
    fn strip_uuid_qualifier_leaves_a_uid_without_one_untouched() {
        assert_eq!(
            strip_uuid_qualifier("pkg:pypi/requests@2.0"),
            "pkg:pypi/requests@2.0"
        );
        assert_eq!(strip_uuid_qualifier(""), "");
    }

    /// Spec-rule matrix: representative PURL per type asserted against the
    /// canonical form. Guards every parser against drift.
    #[test]
    fn normalize_purl_matrix() {
        let cases = [
            // pypi: full PEP 503 (lowercase + collapse `-_.` runs).
            (
                "pkg:pypi/typing_extensions@4.0.0",
                "pkg:pypi/typing-extensions@4.0.0",
            ),
            ("pkg:pypi/Django@4.2", "pkg:pypi/django@4.2"),
            ("pkg:pypi/zope.interface@5.0", "pkg:pypi/zope-interface@5.0"),
            ("pkg:pypi/foo__bar@1.0", "pkg:pypi/foo-bar@1.0"),
            // composer: lowercase vendor namespace + name.
            (
                "pkg:composer/Monolog/Monolog@2.0",
                "pkg:composer/monolog/monolog@2.0",
            ),
            // hex: lowercase namespace + name.
            ("pkg:hex/Phoenix@1.7.0", "pkg:hex/phoenix@1.7.0"),
            // github / gitlab / bitbucket: lowercase namespace + name.
            (
                "pkg:github/Package-Url/purl-Spec@1.0",
                "pkg:github/package-url/purl-spec@1.0",
            ),
            ("pkg:gitlab/FooBar/Baz@2.0", "pkg:gitlab/foobar/baz@2.0"),
            (
                "pkg:bitbucket/Birkenfeld/Pygments@2.0",
                "pkg:bitbucket/birkenfeld/pygments@2.0",
            ),
            // golang: lowercase host only, preserve path-part case.
            (
                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
            ),
            (
                "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
                "pkg:golang/github.com/Azure/azure-sdk-for-go@1.0",
            ),
        ];

        for (input, expected) in cases {
            assert_eq!(normalize_purl(input), expected, "input: {input}");
        }
    }

    /// Case-sensitive and custom types must be returned byte-for-byte unchanged.
    #[test]
    fn normalize_purl_preserves_case_sensitive_types() {
        let untouched = [
            // npm grandfathers mixed-case names.
            "pkg:npm/%40angular/Core@13.0.0",
            "pkg:maven/com.Example/MyLib@1.0",
            "pkg:cargo/Serde@1.0",
            "pkg:gem/RSpec@3.0",
            // Unknown / custom type with no registered spec.
            "pkg:bower/SomeLib@1.0",
        ];

        for purl in untouched {
            assert_eq!(normalize_purl(purl), purl, "input: {purl}");
        }
    }

    #[test]
    fn normalize_purl_is_idempotent() {
        let inputs = [
            "pkg:pypi/typing_extensions@4.0.0",
            "pkg:composer/Monolog/Monolog@2.0",
            "pkg:golang/GitHub.com/Azure/azure-sdk-for-go@1.0",
            "pkg:github/Foo/Bar",
        ];

        for input in inputs {
            let once = normalize_purl(input);
            let twice = normalize_purl(&once);
            assert_eq!(once, twice, "not idempotent for {input}");
        }
    }

    #[test]
    fn normalize_purl_preserves_qualifiers_and_subpath() {
        // pypi name changes, but qualifiers and subpath survive the round-trip.
        assert_eq!(
            normalize_purl("pkg:pypi/typing_extensions@4.0?arch=any#sub/path"),
            "pkg:pypi/typing-extensions@4.0?arch=any#sub/path",
        );
    }

    #[test]
    fn normalize_purl_returns_unparsable_input_unchanged() {
        assert_eq!(normalize_purl("not-a-purl"), "not-a-purl");
        assert_eq!(normalize_purl(""), "");
    }

    #[test]
    fn normalize_purl_handles_pypi_without_version() {
        assert_eq!(
            normalize_purl("pkg:pypi/typing_extensions"),
            "pkg:pypi/typing-extensions",
        );
    }

    /// A golang PURL with no namespace (single-segment module path): the whole
    /// `rest` slice is lowercased, which is the correct host-only rule when the
    /// module name itself is the host (e.g. the standard library placeholder).
    #[test]
    fn normalize_purl_golang_no_namespace() {
        assert_eq!(
            normalize_purl("pkg:golang/Std@go1.21"),
            "pkg:golang/std@go1.21",
        );
        // Already lowercase — unchanged.
        assert_eq!(
            normalize_purl("pkg:golang/std@go1.21"),
            "pkg:golang/std@go1.21",
        );
    }

    /// Qualifiers and subpath on a golang PURL must survive the raw-string edit.
    #[test]
    fn normalize_purl_golang_preserves_qualifiers_and_subpath() {
        assert_eq!(
            normalize_purl(
                "pkg:golang/GITHUB.COM/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path"
            ),
            "pkg:golang/github.com/Azure/pkg@1.0?vcs_url=https://github.com/Azure/pkg#sub/path",
        );
    }

    /// A golang PURL whose type prefix is not all-lowercase is returned unchanged
    /// because `lowercase_first_path_segment` relies on the crate serializer
    /// always emitting a lowercase type; all PURL-generating paths in this code
    /// base go through that serializer so this edge is never triggered in practice.
    #[test]
    fn normalize_purl_golang_mixed_case_type_unchanged() {
        // The crate's serializer always lowercases the type, so "pkg:Golang/"
        // never appears in practice. The function documents this no-op contract.
        assert_eq!(
            normalize_purl("pkg:Golang/GITHUB.COM/Azure/pkg@1.0"),
            "pkg:Golang/GITHUB.COM/Azure/pkg@1.0",
        );
    }
}