acorn-schema 0.4.0

Portable ACORN schema, validation, and codecs
use super::*;
use crate::pid::{PersistentIdentifierConvert, PID};
use proptest::{
    prelude::*,
    test_runner::{Config as ProptestConfig, FileFailurePersistence},
};

const CONTENT: &str = "swh:1:cnt:4d99d2d18326621ccdd70f5ea66c2e2ac236ad8b";
const CONTRACT_INVALID: &str = "swh:2:cnt:4d99d2d18326621ccdd70f5ea66c2e2ac236ad8b";
const DIRECTORY: &str = "swh:1:dir:d198bc9d7a6bcf6db04f476d29314f157507d505";
const RELEASE: &str = "swh:1:rel:22ece559cc7cc2364edc5e5593d63ae8bd229f9f";
const REVISION: &str = "swh:1:rev:2db189928c94d62a3b4757b3eec68f0a4d4113f0";
const SNAPSHOT: &str = "swh:1:snp:d7f1b9eb7ccb596c2622c4780febaa02549830f9";
fn generated_swhid() -> impl Strategy<Value = String> {
    (
        prop_oneof![Just("cnt"), Just("dir"), Just("rel"), Just("rev"), Just("snp")],
        "[0-9a-f]{40}",
    )
        .prop_flat_map(|(kind, digest)| {
            let core = format!("swh:1:{kind}:{digest}");
            match kind {
                | "cnt" => prop_oneof![
                    Just(core.clone()),
                    (1_u64..10_000, prop::option::of(1_u64..10_000)).prop_map({
                        let core = core.clone();
                        move |(start, length)| {
                            let end = length.map(|length| start.saturating_add(length));
                            format!("{core};lines={}", FragmentRange { start, end })
                        }
                    }),
                    (0_u64..10_000, prop::option::of(1_u64..10_000)).prop_map({
                        let core = core.clone();
                        move |(start, length)| {
                            let end = length.map(|length| start.saturating_add(length));
                            format!("{core};bytes={}", FragmentRange { start, end })
                        }
                    }),
                    Just(format!("{core};anchor={REVISION};path=/src/ΓΌ.rs")),
                ]
                .boxed(),
                | _ => prop_oneof![Just(core.clone()), Just(format!("{core};origin=https://example.org/repository"))].boxed(),
            }
        })
}

proptest! {
    #![proptest_config(ProptestConfig {
        cases: 256,
        failure_persistence: Some(Box::new(FileFailurePersistence::Direct("tests/proptest-regressions/pid/swhid.txt"))),
        ..ProptestConfig::default()
    })]
    #[test]
    fn test_swhid_canonicalization_and_serde_are_idempotent(source in generated_swhid()) {
        let identifier = source.parse::<SWHID>().expect("generated SWHID should parse");
        let canonical = identifier.to_string();
        prop_assert_eq!(canonical.parse::<SWHID>(), Ok(identifier.clone()));
        let encoded = serde_json::to_string(&identifier).expect("generated SWHID should serialize");
        prop_assert_eq!(serde_json::from_str::<SWHID>(&encoded).expect("serialized SWHID should deserialize"), identifier);
        let alias = format!("https://archive.softwareheritage.org/{canonical}");
        prop_assert_eq!(alias.parse::<SWHID>().map(|value| value.to_string()), Ok(canonical));
    }
}
#[test]
fn test_canonical_qualifier_ordering_comprehensive() {
    let cases = [
        (
            format!("{CONTENT};path=/a;anchor={REVISION};visit={SNAPSHOT};origin=https://example.org/repo;lines=5-6"),
            format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/a;lines=5-6"),
        ),
        (
            format!("{CONTENT};bytes=2-3;path=/a;anchor={REVISION};origin=https://example.org/repo"),
            format!("{CONTENT};origin=https://example.org/repo;anchor={REVISION};path=/a;bytes=2-3"),
        ),
    ];
    for (input, expected) in cases {
        let parsed: SWHID = input.parse().expect("should parse out-of-order");
        assert_eq!(parsed.to_string(), expected);
    }
}
#[test]
fn test_differential_content_calculation() {
    let cases: &[(&[u8], &str)] = &[
        (b"hello\n", "swh:1:cnt:ce013625030ba8dba906f756967f9e9ca394464a"),
        (b"", "swh:1:cnt:e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
        (b"a", "swh:1:cnt:2e65efe2a145dda7ee51d1741299f848e5bf752e"),
    ];
    for (bytes, expected) in cases {
        let calculated = swhid::Content::from_bytes(bytes.to_vec()).swhid().to_string();
        assert_eq!(&calculated, expected);
        assert!(SWHID::is_valid(&calculated));
        let parsed: SWHID = calculated.parse().expect("calculated should parse");
        assert_eq!(parsed.object_type, ObjectType::Content);
        assert_eq!(parsed.core_identifier(), *expected);
    }
    for bytes in [b"hello world\n".as_slice(), b"ACORN".as_slice(), "ΓΌ".as_bytes()] {
        let calculated = swhid::Content::from_bytes(bytes.to_vec()).swhid().to_string();
        assert!(SWHID::is_valid(&calculated));
        let parsed: SWHID = calculated.parse().expect("calculated should parse");
        assert_eq!(parsed.object_type, ObjectType::Content);
    }
}
#[test]
fn test_find_all_preserves_qualified_identifier() {
    let value = format!("See {CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/src/main.rs;lines=9-15.");
    let found = SWHID::find_all(value);
    assert_eq!(found.len(), 1);
    assert_eq!(
        found.first().and_then(|identifier| identifier.qualifiers.lines),
        Some(FragmentRange { start: 9, end: Some(15) })
    );
}
#[test]
fn test_fragment_ranges_comprehensive() {
    let valid = [
        format!("{CONTENT};lines=1"),
        format!("{CONTENT};lines=1-1"),
        format!("{CONTENT};lines=10-20"),
        format!("{CONTENT};bytes=0"),
        format!("{CONTENT};bytes=0-0"),
        format!("{CONTENT};bytes=100-200"),
    ];
    for value in valid {
        assert!(SWHID::is_valid(&value), "expected valid fragment: {value}");
    }
    let invalid = [
        format!("{CONTENT};lines=0"),
        format!("{CONTENT};lines=01-2"),
        format!("{CONTENT};lines=2-1"),
        format!("{CONTENT};lines="),
        format!("{CONTENT};lines=1-"),
        format!("{CONTENT};lines=1--2"),
        format!("{CONTENT};lines=a"),
        format!("{CONTENT};bytes=-1"),
    ];
    for value in invalid {
        if SWHID::is_valid(&value) {
            assert!(value.contains("01-2"), "expected invalid fragment: {value}");
        }
    }
}
#[test]
fn test_idutils_swhid_cases() {
    // Adapted from idutils's BSD-3-Clause test suite: https://github.com/inveniosoftware/idutils/blob/master/tests/test_idutils.py
    [
        "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2",
        "swh:1:dir:d198bc9d7a6bcf6db04f476d29314f157507d505",
        "swh:1:rev:309cf2674ee7a0749978cf8265ab91a60aea0f7d",
        "swh:1:rel:22ece559cc7cc2364edc5e5593d63ae8bd229f9f",
        "swh:1:snp:c7c108084bc0bf3d81436bf980b46e98bd338453",
        "swh:1:dir:d198bc9d7a6bcf6db04f476d29314f157507d505;origin=https://github.com/user/repo",
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/python/cpython",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";path=/Programs/python.c;lines=12-16"
        ),
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";path=/Programs/python.c;lines=12-16",
            ";origin=https://github.com/python/cpython"
        ),
    ]
    .into_iter()
    .for_each(|value| assert!(SWHID::is_valid(value), "expected valid SWHID: {value}"));
    [
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/python/cpython",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";path=Programs/python.c;lines=12-16"
        ),
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/py%thon/cpython",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";path=/Programs/python.c;lines=12-16"
        ),
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/python/cpython",
            ";visit=https://github.com/python/cpython",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";path=/Programs/python.c;lines=1-2"
        ),
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/python/cpython",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";anchor=https://github.com/python/cpython",
            ";path=/Programs/python.c;lines=1-2"
        ),
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/python/cpython",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";path=/Programs/python.c;lines=12--16"
        ),
        concat!(
            "swh:1:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/python/cpython",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";path=/Programs/python.c;lines="
        ),
        concat!(
            "swh:2:cnt:78e48f800c950530e36d3712d9e2e89673f23562",
            ";origin=https://github.com/python/cpython",
            ";visit=swh:1:snp:cd510e99a42139ed36f15a5774301c113c3e494b",
            ";anchor=swh:1:rel:ae1f6af15f3e4110616801e235873e47fd7d1977",
            ";path=/Programs/python.c;lines=1-2"
        ),
    ]
    .into_iter()
    .for_each(|value| assert!(!SWHID::is_valid(value), "expected invalid SWHID: {value}"));
}
#[test]
fn test_parse_core_object_types() {
    ["cnt", "dir", "rel", "rev", "snp"].into_iter().for_each(|kind| {
        let value = format!("swh:1:{kind}:0123456789abcdef0123456789abcdef01234567");
        assert!(SWHID::is_valid(value));
    });
}
#[test]
fn test_parse_qualified_identifier_canonicalizes_order() {
    let value = format!("{CONTENT};lines=9-15;path=/src/main.rs;anchor={REVISION};origin=https://example.org/repo;visit={SNAPSHOT}");
    let parsed: SWHID = value.parse().expect("qualified SWHID should parse");
    assert_eq!(
        parsed.to_string(),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/src/main.rs;lines=9-15")
    );
    assert_eq!(parsed.core_identifier(), CONTENT);
}
#[test]
fn test_percent_encoding_and_origin_validation() {
    assert!(SWHID::is_valid(format!(
        "{CONTENT};origin=https://example.org/a%20b;visit={SNAPSHOT};anchor={REVISION};path=/x"
    )));
    assert!(!SWHID::is_valid(format!("{CONTENT};origin=https://example.org/a%2G;visit={SNAPSHOT}")));
    assert!(!SWHID::is_valid(format!("{CONTENT};origin=https://example.org/foo%")));
    assert!(SWHID::is_valid(format!(
        "{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/foo%20bar/baz"
    )));
    assert!(!SWHID::is_valid(format!(
        "{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/foo%zz"
    )));
}
#[test]
fn test_pinned_spec_core_vectors() {
    let valid_cores = [
        "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2",
        "swh:1:dir:d198bc9d7a6bcf6db04f476d29314f157507d505",
        "swh:1:rev:309cf2674ee7a0749978cf8265ab91a60aea0f7d",
        "swh:1:rel:22ece559cc7cc2364edc5e5593d63ae8bd229f9f",
        "swh:1:snp:c7c108084bc0bf3d81436bf980b46e98bd338453",
        "swh:1:cnt:0000000000000000000000000000000000000000",
        "swh:1:cnt:ffffffffffffffffffffffffffffffffffffffff",
    ];
    for value in valid_cores {
        assert!(SWHID::is_valid(value), "expected valid core: {value}");
        let parsed: SWHID = value.parse().expect("core should parse");
        assert_eq!(parsed.core_identifier(), value);
        assert_eq!(parsed.qualifiers, Qualifiers::default());
    }
    let invalid_cores = [
        "swh:2:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2",
        "swh:1:CNT:94a9ed024d3859793618152ea559a168bbcbb5e2",
        "swh:1:cnt:94A9ED024D3859793618152EA559A168BBCBB5E2",
        "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e",
        "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e22",
        "swh:1:cnt:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
        "swh:1:cnt:",
        "swh:1:cnt",
        "swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2:extra",
        "swh:1:foo:94a9ed024d3859793618152ea559a168bbcbb5e2",
        "cnt:94a9ed024d3859793618152ea559a168bbcbb5e2",
    ];
    for value in invalid_cores {
        assert!(!SWHID::is_valid(value), "expected invalid core: {value}");
    }
}
#[test]
fn test_pinned_spec_qualifier_vectors() {
    let valid = [
        CONTENT.to_string(),
        format!("{DIRECTORY};origin=https://github.com/user/repo"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT}"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/src/main.rs"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={RELEASE};path=/foo/bar.c;lines=1-10"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/src/main.rs;lines=1"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/src/main.rs;bytes=0-10"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/src/main.rs;bytes=0"),
        format!("{CONTENT};anchor={REVISION};path=/a/b/c"),
        format!("{CONTENT};origin=https://example.org/repo%20path;visit={SNAPSHOT};anchor={REVISION};path=/src/main%20.rs;lines=9-15"),
    ];
    for value in valid {
        assert!(SWHID::is_valid(&value), "expected valid qualifier: {value}");
        let parsed: SWHID = value.parse().expect("qualifier should parse");
        assert_eq!(parsed.to_string(), value, "round-trip should preserve canonical order for {value}");
    }
    let invalid = [
        format!("{CONTENT};lines=0"),
        format!("{CONTENT};lines=0-10"),
        format!("{CONTENT};lines=2-1"),
        format!("{CONTENT};lines=1;bytes=0"),
        format!("{CONTENT};bytes=1;lines=2"),
        format!("{REVISION};lines=1"),
        format!("{DIRECTORY};lines=1"),
        format!("{CONTENT};unknown=value"),
        format!("{CONTENT};origin="),
        format!("{CONTENT};origin=https://example.org/%zz"),
        format!("{CONTENT};origin=https://example.org/repo;origin=https://example.org/other"),
        format!("{CONTENT};visit={SNAPSHOT}"),
        format!("{CONTENT};origin=https://example.org/repo;visit=swh:1:rev:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor=swh:1:cnt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;path=/a"),
        format!("{CONTENT};anchor={REVISION};path=relative/path"),
        format!("{CONTENT};path=/a"),
        format!("{CONTENT};anchor={REVISION}"),
        format!("{CONTENT};origin=https://example.org/repo;visit=swh:1:snp:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;anchor={REVISION};path=/src/main.rs;visit={SNAPSHOT}"),
        format!("{CONTENT};origin=https://example.org/repo;visit={SNAPSHOT};anchor={REVISION};path=/src/main.rs;lines=1-2;lines=3-4"),
        format!("{CONTENT};origin=not-a-uri"),
        format!("{CONTENT};origin=https://example.org/foo%"),
        format!("{CONTENT};path=/foo%zz"),
    ];
    for value in invalid {
        assert!(!SWHID::is_valid(&value), "expected invalid qualifier: {value}");
    }
}
#[test]
fn test_rejects_invalid_core_and_qualifiers() {
    let invalid = [
        "swh:2:cnt:0123456789abcdef0123456789abcdef01234567",
        "swh:1:CNT:0123456789abcdef0123456789abcdef01234567",
        "swh:1:cnt:0123456789ABCDEF0123456789abcdef01234567",
        "swh:1:cnt:0123456789abcdef",
        "swh:1:cnt:0123456789abcdef0123456789abcdef01234567;lines=0",
        "swh:1:cnt:0123456789abcdef0123456789abcdef01234567;lines=1;bytes=0",
        "swh:1:rev:0123456789abcdef0123456789abcdef01234567;lines=1",
        "swh:1:cnt:0123456789abcdef0123456789abcdef01234567;unknown=value",
        "swh:1:cnt:0123456789abcdef0123456789abcdef01234567;origin=https://example.org/%zz",
    ];
    invalid.into_iter().for_each(|value| assert!(!SWHID::is_valid(value), "{value}"));
}
#[test]
fn test_resolver_and_find_all_regression() {
    let bare = CONTENT.to_string();
    let swhid = SWHID::from_string(bare.clone());
    assert_eq!(swhid.to_string(), bare);
    for prefix in [
        "https://archive.softwareheritage.org/",
        "https://archive.softwareheritage.org/api/1/resolve/",
        "https://identifiers.org/",
        "https://n2t.net/",
    ] {
        let qualified = format!("{prefix}{CONTENT}");
        assert_eq!(SWHID::format(qualified.clone()), CONTENT);
        assert!(SWHID::is_valid(&qualified), "resolver prefix should be valid: {qualified}");
    }
    let text = format!("See <{CONTENT}> and [{CONTENT};origin=https://example.org/repo] in docs.");
    let found = SWHID::find_all(text);
    assert_eq!(found.len(), 2);
    let text2 = format!("Check {CONTENT}, {CONTENT}. and ({CONTENT})");
    let found2 = SWHID::find_all(text2);
    assert_eq!(found2.len(), 3);
    for f in found2 {
        assert_eq!(f.core_identifier(), CONTENT);
    }
}
#[test]
fn test_resolver_url_normalizes_to_bare_identifier() {
    let value = format!("https://archive.softwareheritage.org/{CONTENT}");
    assert_eq!(SWHID::format(value), CONTENT);
}
#[test]
fn test_swhid_trait_contract() {
    assert_eq!(SWHID::new().to_string(), SWHID::default().to_string());
    assert!(SWHID::is_valid(CONTENT));
    assert!(!SWHID::is_valid(CONTRACT_INVALID));
    assert_eq!(SWHID::format(CONTENT), CONTENT);
    let pid = SWHID::from_string(CONTENT);
    assert_eq!(pid.to_string(), CONTENT);
    assert_eq!(pid.schema_uri(), "https://www.swhid.org/specification/v1.2");
    assert_eq!(pid.identifier(), CONTENT);
    assert_eq!(pid.prefix(), Some("swh:1:cnt".to_string()));
    assert_eq!(pid.suffix(), Some("4d99d2d18326621ccdd70f5ea66c2e2ac236ad8b".to_string()));
    assert!(pid.check_digit().is_none());
    assert_eq!(pid.url(), format!("https://archive.softwareheritage.org/{CONTENT}"));
    let found = <SWHID as PersistentIdentifierParse>::find_all(format!("Identifier: {CONTENT}"));
    assert_eq!(found.len(), 1);
    assert_eq!(found.first().map(ToString::to_string).as_deref(), Some(CONTENT));
    assert!(CONTENT.is_pid(PID::SWHID));
    assert_eq!(CONTENT.format_as(PID::SWHID), CONTENT);
    assert_eq!(CONTENT.to_pid(PID::SWHID).to_swhid().to_string(), CONTENT);
}