lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit cc80ef13c0749572901db232b551a2f14086f785
parent 4c74c57bb4aa1ac17daad9cf168d303030379a80
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 03:09:21 +0000

blossom: add byte-verified blob descriptors

- add portable BUD-01 hash paths and structural URLs
- separate reference approval from descriptor byte verification
- execute canonical vectors in workspace and packaged crates
- register public contracts coverage and Nix test inclusion

Diffstat:
MCargo.lock | 18++++++++++++++++++
MCargo.toml | 4++++
Mbuild/nix/common.nix | 1+
Acontracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json | 1273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/coverage.toml | 1+
Acontracts/events/blossom-media.md | 231+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/manifest.toml | 4+++-
Mcontracts/operations.toml | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcontracts/version.toml | 1+
Acrates/blossom/Cargo.toml | 31+++++++++++++++++++++++++++++++
Acrates/blossom/README | 19+++++++++++++++++++
Acrates/blossom/src/descriptor.rs | 493+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/src/error.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/src/hash.rs | 354+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/src/lib.rs | 20++++++++++++++++++++
Acrates/blossom/src/url.rs | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/tests/conformance.rs | 387+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json | 1273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
18 files changed, 4693 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3031,6 +3031,12 @@ dependencies = [ ] [[package]] +name = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" + +[[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4275,6 +4281,18 @@ dependencies = [ ] [[package]] +name = "radroots_blossom" +version = "0.1.0-alpha.2" +dependencies = [ + "hex", + "mediatype", + "serde", + "serde_json", + "sha2", + "url", +] + +[[package]] name = "radroots_core" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/event_codec", "crates/event_index", "crates/authority", + "crates/blossom", "crates/libsqlite3_sys_3_53_3", "crates/geocoder", "crates/identity", @@ -72,6 +73,7 @@ radroots_event_store = { path = "crates/event_store", version = "0.1.0-alpha.2", radroots_event_codec = { path = "crates/event_codec", version = "0.1.0-alpha.2", default-features = false } radroots_event_index = { path = "crates/event_index", version = "0.1.0-alpha.2", default-features = false } radroots_authority = { path = "crates/authority", version = "0.1.0-alpha.2", default-features = false } +radroots_blossom = { path = "crates/blossom", version = "0.1.0-alpha.2", default-features = false } radroots_geocoder = { path = "crates/geocoder", version = "0.1.0-alpha.2" } radroots_identity = { path = "crates/identity", version = "0.1.0-alpha.2", default-features = false } radroots_runtime_store = { path = "crates/runtime_store", version = "0.1.0-alpha.2", default-features = false } @@ -139,6 +141,7 @@ getrandom = { version = "0.2", default-features = false } hkdf = { version = "0.12", default-features = false } hex = { version = "0.4" } js-sys = { version = "0.3" } +mediatype = { version = "0.21", default-features = false } keyring = { version = "3.6.3", default-features = false, features = [ "apple-native", "windows-native", @@ -194,6 +197,7 @@ tracing-appender = { version = "0.2" } tracing-log = { version = "0.2" } tracing-subscriber = { version = "0.3" } url = { version = "2" } +url_nostd = { package = "url", version = "2", default-features = false } uuid = { version = "1.22.0", features = ["v4", "v7"] } x509-parser = { version = "0.17", default-features = false } zstd = { version = "0.13", default-features = false } diff --git a/build/nix/common.nix b/build/nix/common.nix @@ -87,6 +87,7 @@ let releaseRuntimeInputs = coverageRuntimeInputs; coreContractCrates = [ "xtask" + "radroots_blossom" "radroots_core" "radroots_event" "radroots_trade" diff --git a/contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json b/contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json @@ -0,0 +1,1273 @@ +{ + "suite": "blossom_hash_path_and_descriptor", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "blossom_sha256_digest_empty_001", + "kind": "blossom.sha256.digest", + "input": { + "bytes_hex": "" + }, + "expected": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "id": "blossom_sha256_digest_abc_002", + "kind": "blossom.sha256.digest", + "input": { + "bytes_hex": "616263" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + } + }, + { + "id": "blossom_sha256_digest_binary_003", + "kind": "blossom.sha256.digest", + "input": { + "bytes_hex": "89504e470d0a1a0a726164726f6f7473" + }, + "expected": { + "sha256": "a829dda89323e71a4575c1c45c52d65c3a0c87dd8ffcdab9ad12bbee57c00861" + } + }, + { + "id": "blossom_sha256_parse_valid_004", + "kind": "blossom.sha256.parse.valid", + "input": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + } + }, + { + "id": "blossom_sha256_parse_zero_valid_005", + "kind": "blossom.sha256.parse.valid", + "input": { + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "expected": { + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "id": "blossom_sha256_parse_short_invalid_006", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "ba7816bf8f01cfea" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_sha256_parse_uppercase_invalid_007", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_sha256_parse_non_hex_invalid_008", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ag" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_hash_path_without_extension_009", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "expected": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "extension": null + } + }, + { + "id": "blossom_hash_path_png_extension_010", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "png" + } + }, + { + "id": "blossom_hash_path_multi_extension_011", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.tar.gz" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "tar.gz" + } + }, + { + "id": "blossom_hash_path_extension_case_preserved_012", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.Photo_JPG-2" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "Photo_JPG-2" + } + }, + { + "id": "blossom_hash_path_missing_root_slash_013", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_empty_014", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_extra_segment_015", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad/png" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_uppercase_hash_016", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD.png" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_hash_path_empty_extension_017", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad." + }, + "expected": { + "error": "invalid_file_extension" + } + }, + { + "id": "blossom_hash_path_empty_extension_component_018", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.tar..gz" + }, + "expected": { + "error": "invalid_file_extension" + } + }, + { + "id": "blossom_hash_path_whitespace_extension_019", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.p ng" + }, + "expected": { + "error": "invalid_file_extension" + } + }, + { + "id": "blossom_hash_path_percent_encoded_extension_020", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.%70ng" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_query_forbidden_021", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png?download=1" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_blob_url_https_canonical_authority_022", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "HTTPS://MEDIA.EXAMPLE/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.PNG" + }, + "expected": { + "scheme": "https", + "host": "media.example", + "port": null, + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "PNG" + } + }, + { + "id": "blossom_blob_url_public_http_structural_023", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "scheme": "http", + "host": "media.example", + "port": null, + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "png" + } + }, + { + "id": "blossom_blob_url_https_port_without_extension_024", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "https://media.example:8443/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "expected": { + "scheme": "https", + "host": "media.example", + "port": 8443, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "extension": null + } + }, + { + "id": "blossom_blob_url_bracketed_ipv6_025", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "http://[::1]:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.bin" + }, + "expected": { + "scheme": "http", + "host": "[::1]", + "port": 21100, + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "bin" + } + }, + { + "id": "blossom_blob_url_relative_invalid_026", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_unsupported_scheme_027", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "ftp://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "unsupported_blob_url_scheme" + } + }, + { + "id": "blossom_blob_url_missing_authority_028", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_credentials_forbidden_029", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://user:password@media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "blob_url_credentials_forbidden" + } + }, + { + "id": "blossom_blob_url_empty_userinfo_forbidden_029b", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://@media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "blob_url_credentials_forbidden" + } + }, + { + "id": "blossom_blob_url_empty_userinfo_separator_forbidden_029c", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://:@media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "blob_url_credentials_forbidden" + } + }, + { + "id": "blossom_blob_url_query_forbidden_030", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png?download=1" + }, + "expected": { + "error": "blob_url_query_forbidden" + } + }, + { + "id": "blossom_blob_url_fragment_forbidden_031", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png#preview" + }, + "expected": { + "error": "blob_url_fragment_forbidden" + } + }, + { + "id": "blossom_blob_url_extra_path_segment_032", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/blobs/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_blob_url_port_out_of_range_033", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example:65536/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_uppercase_hash_034", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD.png" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_blob_url_percent_encoded_extension_035", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.%70ng" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_unbracketed_ipv6_036", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "http://::1/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.bin" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_leading_whitespace_rejected_036b", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": " https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_embedded_newline_rejected_036c", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/\nba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_trailing_tab_rejected_036d", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png\t" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_reference_https_public_approved_037", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "https" + } + }, + { + "id": "blossom_reference_https_private_address_approved_038", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "https://10.0.0.4/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "https" + } + }, + { + "id": "blossom_reference_http_localhost_approved_039", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://LOCALHOST:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_localhost_subdomain_approved_040", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://media.localhost/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_nested_localhost_subdomain_approved_041", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://blossom.dev.localhost/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_ipv4_loopback_approved_042", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://127.0.0.1:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_ipv4_loopback_range_approved_043", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://127.255.255.254/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_ipv6_loopback_approved_044", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://[::1]:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_expanded_ipv6_loopback_approved_045", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://[0:0:0:0:0:0:0:1]/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_public_rejected_046", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_false_localhost_suffix_rejected_047", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://localhost.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_absolute_localhost_name_rejected_048", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://localhost./ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_before_ipv4_loopback_range_rejected_049", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://126.255.255.255/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_after_ipv4_loopback_range_rejected_050", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://128.0.0.0/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_private_ipv4_rejected_051", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://10.0.0.4/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_unspecified_ipv4_rejected_052", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://0.0.0.0/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_ipv6_link_local_rejected_053", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://[fe80::1]/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_ipv4_mapped_ipv6_rejected_054", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://[::ffff:127.0.0.1]/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_blob_url_short_ipv4_spelling_rejected_055", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "http://127.1/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_reference_http_private_ipv4_second_range_rejected_056", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://192.168.1.4/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_media_type_image_png_057", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "image/png" + }, + "expected": { + "media_type": "image/png" + } + }, + { + "id": "blossom_media_type_case_canonicalized_058", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "IMAGE/PNG" + }, + "expected": { + "media_type": "image/png" + } + }, + { + "id": "blossom_media_type_vendor_token_059", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "application/vnd.radroots.fixture+json" + }, + "expected": { + "media_type": "application/vnd.radroots.fixture+json" + } + }, + { + "id": "blossom_media_type_empty_060", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_parameters_sorted_and_canonicalized_061", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "TEXT/PLAIN; PROFILE=web; CHARSET=UTF-8" + }, + "expected": { + "media_type": "text/plain; charset=UTF-8; profile=web" + } + }, + { + "id": "blossom_media_type_duplicate_parameter_rejected_061b", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/png; profile=a; PROFILE=b" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_missing_separator_062", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "imagepng" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_empty_subtype_063", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_wildcard_forbidden_064", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/*" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_parser_truncated_whitespace_forbidden_065", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/png " + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_non_ascii_forbidden_066", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/p\u00f1g" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_descriptor_https_valid_067", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + } + }, + { + "id": "blossom_descriptor_empty_blob_extra_fields_valid_068", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.bin", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "type": "application/octet-stream", + "uploaded": 0, + "blurhash": "fixture-only-extra-field", + "custom": { + "version": 1 + } + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.bin", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "type": "application/octet-stream", + "uploaded": 0 + } + } + }, + { + "id": "blossom_descriptor_extension_does_not_define_mime_069", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.jpg", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000001 + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.jpg", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000001 + } + } + }, + { + "id": "blossom_descriptor_public_http_structurally_valid_070", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000002 + } + }, + "expected": { + "descriptor": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000002 + } + } + }, + { + "id": "blossom_descriptor_media_type_canonicalized_071", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "http://127.0.0.1:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.PNG", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "IMAGE/PNG", + "uploaded": 0 + } + }, + "expected": { + "descriptor": { + "url": "http://127.0.0.1:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.PNG", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 0 + } + } + }, + { + "id": "blossom_descriptor_missing_url_072", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "url" + } + }, + { + "id": "blossom_descriptor_missing_sha256_073", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "sha256" + } + }, + { + "id": "blossom_descriptor_missing_size_074", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "size" + } + }, + { + "id": "blossom_descriptor_missing_type_075", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "type" + } + }, + { + "id": "blossom_descriptor_missing_uploaded_076", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain" + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "uploaded" + } + }, + { + "id": "blossom_descriptor_url_extension_required_077", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "descriptor_extension_required" + } + }, + { + "id": "blossom_descriptor_url_hash_mismatch_078", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "descriptor_hash_mismatch" + } + }, + { + "id": "blossom_descriptor_negative_size_079", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": -1, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_descriptor_size" + } + }, + { + "id": "blossom_descriptor_fractional_size_080", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3.5, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_descriptor_size" + } + }, + { + "id": "blossom_descriptor_negative_uploaded_081", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": -1 + } + }, + "expected": { + "error": "invalid_descriptor_uploaded" + } + }, + { + "id": "blossom_descriptor_parameterized_media_type_valid_082", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "TEXT/PLAIN; PROFILE=mobile; CHARSET=UTF-8", + "uploaded": 1700000000 + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain; charset=UTF-8; profile=mobile", + "uploaded": 1700000000 + } + } + }, + { + "id": "blossom_descriptor_invalid_media_type_082b", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_descriptor_uppercase_sha256_083", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_descriptor_url_query_forbidden_084", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt?download=1", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "blob_url_query_forbidden" + } + }, + { + "id": "blossom_descriptor_approved_https_reference_084b", + "kind": "blossom.descriptor.approve_reference.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "approved_url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt" + } + }, + { + "id": "blossom_descriptor_public_http_reference_rejected_084c", + "kind": "blossom.descriptor.approve_reference.invalid", + "input": { + "descriptor": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_descriptor_verify_abc_valid_085", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "text/plain", + "bytes_hex": "616263" + }, + "expected": { + "verified": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "media_type": "text/plain" + } + } + }, + { + "id": "blossom_descriptor_verify_empty_valid_086", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.bin", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "type": "application/octet-stream", + "uploaded": 1700000000 + }, + "approved_media_type": "application/octet-stream", + "bytes_hex": "" + }, + "expected": { + "verified": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "media_type": "application/octet-stream" + } + } + }, + { + "id": "blossom_descriptor_verify_no_sniff_or_extension_inference_087", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.jpg", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000000 + }, + "approved_media_type": "image/png", + "bytes_hex": "616263" + }, + "expected": { + "verified": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "media_type": "image/png" + } + } + }, + { + "id": "blossom_descriptor_verify_approved_media_type_canonicalized_088", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000000 + }, + "approved_media_type": "IMAGE/PNG", + "bytes_hex": "616263" + }, + "expected": { + "verified": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "media_type": "image/png" + } + } + }, + { + "id": "blossom_descriptor_verify_hash_mismatch_089", + "kind": "blossom.descriptor.verify_bytes.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 7, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "text/plain", + "bytes_hex": "6368616e676564" + }, + "expected": { + "error": "blob_hash_mismatch" + } + }, + { + "id": "blossom_descriptor_verify_size_mismatch_090", + "kind": "blossom.descriptor.verify_bytes.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 4, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "text/plain", + "bytes_hex": "616263" + }, + "expected": { + "error": "blob_size_mismatch" + } + }, + { + "id": "blossom_descriptor_verify_media_type_mismatch_091", + "kind": "blossom.descriptor.verify_bytes.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "application/octet-stream", + "bytes_hex": "616263" + }, + "expected": { + "error": "blob_media_type_mismatch" + } + } + ] +} diff --git a/contracts/coverage.toml b/contracts/coverage.toml @@ -32,6 +32,7 @@ reason = "branch coverage is not applicable while the crate has no measured bran [required] crates = [ "radroots_authority", + "radroots_blossom", "radroots_core", "radroots_event_store", "radroots_event", diff --git a/contracts/events/blossom-media.md b/contracts/events/blossom-media.md @@ -0,0 +1,231 @@ +# Blossom Media Foundation + +Status: active implementation contract + +Scope: public, runtime-independent Radroots primitives for Blossom BUD-01 hash paths, BUD-02 +descriptors, and local descriptor-versus-byte verification. + +## Protocol Basis + +This contract is based on the Blossom repository at commit +`b5bd2801d1763aa635fc8fea7a76597e0eb18990`: + +- [BUD-01: Server requirements and blob retrieval](https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/01.md) +- [BUD-02: Blob upload and descriptor](https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/02.md) + +The upstream BUD rules and the Radroots application profile are distinct layers. A URL can be a +structurally valid Blossom URL without being an approved Radroots reference. Callers must not treat +structural parsing as approval, byte verification, network reachability, or proof that a server +implements Blossom. + +## Ownership And Dependency Boundary + +`radroots_blossom` owns these primitives as a public `no_std + alloc` protocol leaf. It may use pure +hashing and serialization dependencies, but it must not depend on Radroots event models, event +codecs, Nostr, an HTTP client or server runtime, platform policy, or private code. + +This contract does not implement BUD-03 server discovery, BUD-11 authorization, BUD-12 listing, +upload entitlement, object-storage policy, network I/O, redirects, retries, or availability checks. +BUD-11 is added as a separate foundation slice. + +## SHA-256 + +A `RadrootsBlossomSha256` is exactly 32 bytes. Its wire and display form is exactly 64 lowercase +ASCII hexadecimal characters. + +- Parsing rejects uppercase hexadecimal, prefixes, separators, whitespace, non-hexadecimal bytes, + and every length other than 64 characters. +- Serialization and display always emit lowercase hexadecimal. +- Digest construction hashes the exact supplied bytes without text conversion or normalization. +- The empty byte sequence is valid and has its ordinary SHA-256 digest. + +## Root Hash Paths + +A `RadrootsBlossomHashPath` represents one BUD-01 root path: + +```text +/<64-lowercase-hex-sha256>[.<extension>] +``` + +The leading slash is required by the root-path parser. The path has exactly one segment. It has no +query, fragment, percent decoding, dot segment, or trailing slash. + +An extension is returned without its leading period. It consists of one or more nonempty +dot-separated components. Each component contains only ASCII letters, digits, `-`, or `_`. +Extension case is preserved because the remote path may be case-sensitive. Examples include `png`, +`JPG`, and `tar.gz`; empty components, whitespace, path separators, percent escapes, and `..` are +invalid. + +BUD-01 retrieval paths may omit the extension. A URL carried by a BUD-02 blob descriptor must +include one. The extension is not used to infer or validate the descriptor MIME type. + +## Structural Blob URLs + +A `RadrootsBlossomBlobUrl` is an absolute structural BUD-01 URL with these invariants: + +- the scheme is `http` or `https`, compared case-insensitively and exposed canonically in lowercase +- the authority contains one nonempty DNS hostname, canonical dotted-decimal IPv4 address, or + bracketed IPv6 address and may contain a valid decimal port +- user information is forbidden +- the path is exactly one root hash path +- query and fragment components are forbidden +- the path digest is exposed as a typed `RadrootsBlossomSha256` +- an optional safe extension is parsed independently from the digest + +Scheme and DNS host case are canonicalized to lowercase by the URL parser. Extension case is +preserved. Noncanonical, shortened, octal, hexadecimal, or otherwise ambiguous IPv4 spellings are +rejected before a typed URL is returned; this prevents approval behavior from changing after a +serialization round trip. Raw user-information delimiters, whitespace, control characters, +percent-encoded path data, malformed or zero ports, unbracketed IPv6 addresses, and additional path +segments are also rejected before URL-parser normalization can discard or reinterpret them. + +Structural validity proves only that the URL has a BUD-01-compatible shape. It does not approve the +transport scheme, perform DNS resolution, follow a redirect, issue `HEAD` or `GET`, or establish +that bytes exist at the URL. + +## Approved Reference Policy + +Radroots reference approval is a second, explicit validation step over a structurally valid blob +URL: + +- every structurally valid `https` URL is transport-approved +- `http` is approved only for a syntactic loopback host +- the loopback hostname set is case-insensitive exact `localhost`, or a hostname with at least one + nonempty label ending in `.localhost` +- the loopback IPv4 set is canonical dotted-decimal `127.0.0.0/8` +- the loopback IPv6 set is the address `::1`, including equivalent expanded IPv6 spelling + +The policy does not use DNS resolution. A public hostname that resolves to loopback is not accepted +as loopback. `localhost.`, `localhost.example`, IPv4-mapped IPv6 addresses, private or link-local +addresses, unspecified addresses, and arbitrary public hosts are not approved for `http`. Expanded +IPv6 spellings that parse exactly to `::1` remain structurally valid and approved. + +HTTPS approval is a reference-transport rule, not an SSRF or egress policy. Network runtimes remain +responsible for their own destination, redirect, DNS-rebinding, response-size, and timeout controls. + +## Media Types + +A `RadrootsBlossomMediaType` is a syntactically valid MIME media type parsed by the repository's +`mediatype` dependency. MIME parameters are supported. The canonical representation lowercases the +type, subtype, suffix, and parameter names, sorts parameters by name, and preserves parameter +values. Duplicate parameter names are rejected. Semantic equality is case-insensitive for those +names, while parameter values retain the media-type library's case-sensitive semantics. + +Empty values, missing type/subtype structure, wildcards, invalid parameter syntax, leading or +parser-truncated trailing whitespace, control characters, and non-ASCII token characters are +rejected. + +Parsing never substitutes a default for empty or malformed input. A caller that intends +`application/octet-stream` must provide it explicitly. + +The media type used for byte verification is selected and approved by the caller. This foundation +does not sniff or infer content type from file bytes or the URL extension. + +## BUD-02 Descriptors + +A structural `RadrootsBlossomBlobDescriptor` contains the five required BUD-02 fields: + +| Field | Contract | +| --- | --- | +| `url` | structurally valid absolute root blob URL with an extension | +| `sha256` | typed lowercase SHA-256 equal to the digest in `url` | +| `size` | unsigned 64-bit byte count | +| `type` | parsed `RadrootsBlossomMediaType` | +| `uploaded` | unsigned 64-bit Unix timestamp | + +Unknown descriptor fields are tolerated on input for forward compatibility. They need not be +retained when the typed descriptor is serialized. Required fields, field types, URL structure, URL +extension presence, URL/hash equality, and MIME syntax are validated during construction and +deserialization; public fields must not permit bypassing those invariants. + +Descriptor parsing is structural. It does not apply the approved-reference scheme policy. An +authored publication flow must explicitly require both a structurally valid descriptor and an +approved descriptor URL. + +The `uploaded` value is parsed as protocol data. Structural parsing does not compare it to a wall +clock or treat it as server attestation. + +## Byte Commitments And Byte-Verified Descriptors + +A structural descriptor advances to `RadrootsBlossomApprovedDescriptor` only after its URL passes +the explicit HTTPS-or-loopback reference policy. `RadrootsBlossomByteCommitment::from_bytes` +computes the SHA-256 and exact `u64` size of a final byte slice and binds them to a caller-approved +`RadrootsBlossomMediaType`. + +An approved descriptor advances to `RadrootsBlossomByteVerifiedDescriptor` only through +`verify_commitment`, or through the `verify_bytes` convenience path that constructs the same byte +commitment. Verification succeeds only when all three values match: + +1. computed byte SHA-256 equals descriptor `sha256` +2. exact byte length equals descriptor `size` +3. caller-approved media type is semantically equal to descriptor `type` + +These comparisons are independent. URL extension does not participate in MIME verification, and +no byte sniffing occurs. + +The resulting byte-verified state proves only that the reference policy approved the descriptor URL, +the supplied descriptor and approved media type describe the supplied bytes, and the descriptor URL +carries the same digest. It is deliberately not named or modeled as an upload receipt. It does not +prove that an upload occurred, the descriptor was issued by the named server, the server is +authorized or reachable, the blob is currently available, a redirect preserves the hash, or a later +retrieval returns those bytes. An HTTP-capable runtime must separately require a successful BUD-02 +upload response before publication and must perform bounded retrieval checks at their owning layer. + +## Stable Conformance Operations + +`contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json` is executable evidence for +this contract. Integration tests dispatch its vectors by `kind`: + +- `blossom.sha256.digest` +- `blossom.sha256.parse.valid` and `blossom.sha256.parse.invalid` +- `blossom.hash_path.parse.valid` and `blossom.hash_path.parse.invalid` +- `blossom.blob_url.parse.valid` and `blossom.blob_url.parse.invalid` +- `blossom.reference_policy.valid` and `blossom.reference_policy.invalid` +- `blossom.media_type.parse.valid` and `blossom.media_type.parse.invalid` +- `blossom.descriptor.parse.valid` and `blossom.descriptor.parse.invalid` +- `blossom.descriptor.approve_reference.valid` and + `blossom.descriptor.approve_reference.invalid` +- `blossom.descriptor.verify_bytes.valid` and + `blossom.descriptor.verify_bytes.invalid` + +`bytes_hex` values are exact bytes encoded as lowercase hexadecimal. A `null` extension or port +means absence. Invalid typed-operation vectors use the exact stable identifier returned by +`RadrootsBlossomError::code()`. + +## Stable Error Identifiers + +The public typed API exposes these stable semantic identifiers: + +- `invalid_sha256` +- `invalid_hash_path` +- `invalid_file_extension` +- `invalid_blob_url` +- `unsupported_blob_url_scheme` +- `blob_url_credentials_forbidden` +- `blob_url_query_forbidden` +- `blob_url_fragment_forbidden` +- `insecure_blob_url` +- `invalid_media_type` +- `descriptor_extension_required` +- `descriptor_hash_mismatch` +- `blob_hash_mismatch` +- `blob_size_mismatch` +- `blob_media_type_mismatch` + +Malformed descriptor JSON can fail in serde before a `RadrootsBlossomError` exists. The executable +descriptor harness uses three additional wire-shape classifications for those cases: + +- `missing_descriptor_field`, with the missing field named separately +- `invalid_descriptor_size` +- `invalid_descriptor_uploaded` + +Nested descriptor values that reach a Radroots parser, including invalid SHA-256, URL, MIME, URL +extension, and URL/hash combinations, retain the applicable `RadrootsBlossomError::code()` value. + +The canonical vector file is mirrored under `crates/blossom/tests/fixtures/` so the published crate +can execute the same conformance suite. Source-workspace tests require the canonical file and assert +that the packaged mirror is byte-for-byte current. + +The public error enum is non-exhaustive so later BUD slices can add typed failures without breaking +consumers. Adding error detail is allowed, but it must not collapse protocol structure, reference +approval, and byte verification into one indistinguishable state. diff --git a/contracts/manifest.toml b/contracts/manifest.toml @@ -6,15 +6,17 @@ source = "rust" [surface] model_crates = [ "radroots_protocol_contract_v1", + "radroots_blossom", "radroots_core", "radroots_event", "radroots_trade", "radroots_identity", ] -algorithm_crates = ["radroots_event_codec"] +algorithm_crates = ["radroots_blossom", "radroots_event_codec"] [surface.rust_crate_tiers] advanced_substrate = [ + "radroots_blossom", "radroots_core", "radroots_event", "radroots_event_codec", diff --git a/contracts/operations.toml b/contracts/operations.toml @@ -5,6 +5,7 @@ source = "rust" [public] domains = [ + "blossom", "profile", "farm", "listing", @@ -16,6 +17,16 @@ domains = [ [shared_types] public = [ + "RadrootsBlossomSha256", + "RadrootsBlossomFileExtension", + "RadrootsBlossomHashPath", + "RadrootsBlossomBlobUrl", + "RadrootsBlossomApprovedBlobUrl", + "RadrootsBlossomMediaType", + "RadrootsBlossomBlobDescriptor", + "RadrootsBlossomApprovedDescriptor", + "RadrootsBlossomByteCommitment", + "RadrootsBlossomByteVerifiedDescriptor", "RadrootsNip01EventWireParts", "RadrootsEventDraft", "RadrootsSignedEvent", @@ -94,12 +105,86 @@ classes = [ [implementation_provenance] model_crates = [ "radroots_protocol_contract_v1", + "radroots_blossom", "radroots_core", "radroots_event", "radroots_trade", "radroots_identity", ] -algorithm_crates = ["radroots_event_codec"] +algorithm_crates = ["radroots_blossom", "radroots_event_codec"] + +[operations.blossom_parse_hash_path] +domain = "blossom" +id = "blossom.parse_hash_path" +stability = "beta" +inputs = ["String"] +outputs = ["RadrootsBlossomHashPath"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_parse_hash_path.implementation] +rust_modules = ["crates/blossom/src/hash.rs"] +rust_types = [ + "radroots_blossom::RadrootsBlossomFileExtension", + "radroots_blossom::RadrootsBlossomHashPath", + "radroots_blossom::RadrootsBlossomSha256", +] + +[operations.blossom_parse_hash_path.conformance] +vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json" + +[operations.blossom_approve_descriptor_reference] +domain = "blossom" +id = "blossom.approve_descriptor_reference" +stability = "beta" +inputs = ["RadrootsBlossomBlobDescriptor"] +outputs = ["RadrootsBlossomApprovedDescriptor"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_approve_descriptor_reference.implementation] +rust_modules = ["crates/blossom/src/descriptor.rs", "crates/blossom/src/url.rs"] +rust_types = [ + "radroots_blossom::RadrootsBlossomApprovedDescriptor", + "radroots_blossom::RadrootsBlossomBlobDescriptor", +] + +[operations.blossom_approve_descriptor_reference.conformance] +vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json" + +[operations.blossom_verify_descriptor_bytes] +domain = "blossom" +id = "blossom.verify_descriptor_bytes" +stability = "beta" +inputs = [ + "RadrootsBlossomApprovedDescriptor", + "Bytes", + "RadrootsBlossomMediaType", +] +outputs = ["RadrootsBlossomByteVerifiedDescriptor"] +error_class = "validation_error" +deterministic = true +signing = "none" +transport = "none" + +[operations.blossom_verify_descriptor_bytes.implementation] +rust_modules = [ + "crates/blossom/src/descriptor.rs", + "crates/blossom/src/hash.rs", +] +rust_types = [ + "radroots_blossom::RadrootsBlossomApprovedDescriptor", + "radroots_blossom::RadrootsBlossomByteCommitment", + "radroots_blossom::RadrootsBlossomByteVerifiedDescriptor", + "radroots_blossom::RadrootsBlossomMediaType", +] + +[operations.blossom_verify_descriptor_bytes.conformance] +vector = "contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json" [operations.profile_build_draft] domain = "profile" diff --git a/contracts/version.toml b/contracts/version.toml @@ -13,6 +13,7 @@ major_on = [ ] minor_on = [ "add_exported_type", + "add_public_foundation_crate", "add_optional_field", "add_enum_variant", "add_conformance_vector", diff --git a/crates/blossom/Cargo.toml b/crates/blossom/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "radroots_blossom" +publish = ["crates-io"] +version = "0.1.0-alpha.2" +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license.workspace = true +description = "Portable Blossom protocol primitives for Radroots" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_blossom" +readme = "README" + +[features] +default = ["serde"] +serde = ["dep:serde"] +std = ["serde?/std", "sha2/std", "url_nostd/std"] + +[dependencies] +mediatype = { workspace = true } +serde = { workspace = true, optional = true } +sha2 = { workspace = true } +url_nostd = { workspace = true } + +[dev-dependencies] +hex = { workspace = true } +serde_json = { workspace = true, features = ["std"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/blossom/README b/crates/blossom/README @@ -0,0 +1,19 @@ +# radroots_blossom + +`radroots_blossom` provides portable, runtime-independent primitives for +Blossom blob hashes, root hash paths, blob URLs, BUD-02 descriptors, and +Radroots-approved byte verification. + +The crate is `no_std + alloc`, performs no HTTP requests, and does not depend on +Nostr event types. Structural Blossom validity is kept separate from the +stricter Radroots reference policy: public HTTP descriptors remain parseable, +but only HTTPS and loopback HTTP references can advance to an approved state. +The byte-verified descriptor state proves local descriptor-to-byte agreement; +it is not an upload receipt, and HTTP-capable runtimes must gate successful +BUD-02 upload completion separately. + +Protocol behavior is pinned to Blossom commit +`b5bd2801d1763aa635fc8fea7a76597e0eb18990`: + +- BUD-01: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/01.md> +- BUD-02: <https://github.com/hzrd149/blossom/blob/b5bd2801d1763aa635fc8fea7a76597e0eb18990/buds/02.md> diff --git a/crates/blossom/src/descriptor.rs b/crates/blossom/src/descriptor.rs @@ -0,0 +1,493 @@ +#[cfg(feature = "serde")] +use alloc::string::String; +use alloc::{collections::BTreeMap, vec::Vec}; +use core::{fmt, str::FromStr}; +use mediatype::{MediaTypeBuf, ReadParams}; + +use crate::{ + RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobUrl, RadrootsBlossomError, + RadrootsBlossomSha256, +}; + +const _: () = assert!(usize::BITS <= u64::BITS); + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsBlossomMediaType(MediaTypeBuf); + +impl RadrootsBlossomMediaType { + pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { + let parsed = + MediaTypeBuf::from_str(value).map_err(|_| RadrootsBlossomError::InvalidMediaType)?; + if parsed.as_str() != value || parsed.ty().as_str() == "*" || parsed.subty().as_str() == "*" + { + return Err(RadrootsBlossomError::InvalidMediaType); + } + let canonical = parsed.canonicalize(); + let mut unique_params = BTreeMap::new(); + for (name, value) in canonical.params() { + if unique_params.insert(name, value).is_some() { + return Err(RadrootsBlossomError::InvalidMediaType); + } + } + let ordered_params = unique_params.into_iter().collect::<Vec<_>>(); + Ok(Self(MediaTypeBuf::from_parts( + canonical.ty(), + canonical.subty(), + canonical.suffix(), + &ordered_params, + ))) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Display for RadrootsBlossomMediaType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsBlossomMediaType { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for RadrootsBlossomMediaType { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsBlossomMediaType { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomBlobDescriptor { + url: RadrootsBlossomBlobUrl, + sha256: RadrootsBlossomSha256, + size: u64, + media_type: RadrootsBlossomMediaType, + uploaded: u64, +} + +impl RadrootsBlossomBlobDescriptor { + pub fn new( + url: RadrootsBlossomBlobUrl, + sha256: RadrootsBlossomSha256, + size: u64, + media_type: RadrootsBlossomMediaType, + uploaded: u64, + ) -> Result<Self, RadrootsBlossomError> { + if url.hash_path().extension().is_none() { + return Err(RadrootsBlossomError::DescriptorExtensionRequired); + } + if url.hash_path().hash() != sha256 { + return Err(RadrootsBlossomError::DescriptorHashMismatch); + } + Ok(Self { + url, + sha256, + size, + media_type, + uploaded, + }) + } + + pub fn url(&self) -> &RadrootsBlossomBlobUrl { + &self.url + } + + pub const fn sha256(&self) -> RadrootsBlossomSha256 { + self.sha256 + } + + pub const fn size(&self) -> u64 { + self.size + } + + pub fn media_type(&self) -> &RadrootsBlossomMediaType { + &self.media_type + } + + pub const fn uploaded(&self) -> u64 { + self.uploaded + } + + pub fn approve_reference( + self, + ) -> Result<RadrootsBlossomApprovedDescriptor, RadrootsBlossomError> { + let approved_url = self.url.clone().approve()?; + Ok(RadrootsBlossomApprovedDescriptor { + descriptor: self, + approved_url, + }) + } +} + +#[cfg(feature = "serde")] +#[derive(serde::Serialize)] +struct DescriptorRef<'a> { + url: &'a RadrootsBlossomBlobUrl, + sha256: RadrootsBlossomSha256, + size: u64, + #[serde(rename = "type")] + media_type: &'a RadrootsBlossomMediaType, + uploaded: u64, +} + +#[cfg(feature = "serde")] +#[derive(serde::Deserialize)] +struct DescriptorWire { + url: RadrootsBlossomBlobUrl, + sha256: RadrootsBlossomSha256, + size: u64, + #[serde(rename = "type")] + media_type: RadrootsBlossomMediaType, + uploaded: u64, +} + +#[cfg(feature = "serde")] +impl serde::Serialize for RadrootsBlossomBlobDescriptor { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + DescriptorRef { + url: &self.url, + sha256: self.sha256, + size: self.size, + media_type: &self.media_type, + uploaded: self.uploaded, + } + .serialize(serializer) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsBlossomBlobDescriptor { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let wire = DescriptorWire::deserialize(deserializer)?; + Self::new( + wire.url, + wire.sha256, + wire.size, + wire.media_type, + wire.uploaded, + ) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomApprovedDescriptor { + descriptor: RadrootsBlossomBlobDescriptor, + approved_url: RadrootsBlossomApprovedBlobUrl, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomByteCommitment { + sha256: RadrootsBlossomSha256, + size: u64, + media_type: RadrootsBlossomMediaType, +} + +impl RadrootsBlossomByteCommitment { + pub fn from_bytes(bytes: &[u8], media_type: RadrootsBlossomMediaType) -> Self { + Self { + sha256: RadrootsBlossomSha256::digest(bytes), + size: bytes.len() as u64, + media_type, + } + } + + pub const fn sha256(&self) -> RadrootsBlossomSha256 { + self.sha256 + } + + pub const fn size(&self) -> u64 { + self.size + } + + pub fn media_type(&self) -> &RadrootsBlossomMediaType { + &self.media_type + } +} + +impl RadrootsBlossomApprovedDescriptor { + pub fn descriptor(&self) -> &RadrootsBlossomBlobDescriptor { + &self.descriptor + } + + pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + &self.approved_url + } + + pub fn into_descriptor(self) -> RadrootsBlossomBlobDescriptor { + self.descriptor + } + + pub fn verify_bytes( + self, + bytes: &[u8], + approved_media_type: &RadrootsBlossomMediaType, + ) -> Result<RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError> { + let commitment = + RadrootsBlossomByteCommitment::from_bytes(bytes, approved_media_type.clone()); + self.verify_commitment(&commitment) + } + + pub fn verify_commitment( + self, + commitment: &RadrootsBlossomByteCommitment, + ) -> Result<RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError> { + if self.descriptor.size != commitment.size { + return Err(RadrootsBlossomError::BlobSizeMismatch { + expected: self.descriptor.size, + actual: commitment.size, + }); + } + if self.descriptor.media_type != commitment.media_type { + return Err(RadrootsBlossomError::BlobMediaTypeMismatch); + } + if self.descriptor.sha256 != commitment.sha256 { + return Err(RadrootsBlossomError::BlobHashMismatch); + } + Ok(RadrootsBlossomByteVerifiedDescriptor(self)) + } +} + +/// An approved descriptor whose hash, size, and media type match supplied bytes. +/// +/// This state does not attest that a network upload occurred. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsBlossomByteVerifiedDescriptor(RadrootsBlossomApprovedDescriptor); + +impl RadrootsBlossomByteVerifiedDescriptor { + pub fn descriptor(&self) -> &RadrootsBlossomBlobDescriptor { + self.0.descriptor() + } + + pub fn url(&self) -> &RadrootsBlossomApprovedBlobUrl { + self.0.url() + } + + pub const fn sha256(&self) -> RadrootsBlossomSha256 { + self.0.descriptor.sha256 + } + + pub const fn size(&self) -> u64 { + self.0.descriptor.size + } + + pub fn media_type(&self) -> &RadrootsBlossomMediaType { + &self.0.descriptor.media_type + } + + pub fn into_descriptor(self) -> RadrootsBlossomBlobDescriptor { + self.0.into_descriptor() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{format, string::ToString}; + + const HELLO_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + + fn descriptor(origin: &str, bytes: &[u8], media_type: &str) -> RadrootsBlossomBlobDescriptor { + let hash = RadrootsBlossomSha256::digest(bytes); + RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!("{origin}/{hash}.txt")).unwrap(), + hash, + u64::try_from(bytes.len()).unwrap(), + RadrootsBlossomMediaType::parse(media_type).unwrap(), + 1_725_105_921, + ) + .unwrap() + } + + #[test] + fn media_type_canonicalizes_parameters_and_compares_case_insensitively() { + let lower = + RadrootsBlossomMediaType::parse("image/svg+xml; profile=web; charset=UTF-8").unwrap(); + let upper = RadrootsBlossomMediaType::from_str("IMAGE/SVG+XML; CHARSET=UTF-8; PROFILE=web") + .unwrap(); + assert_eq!(lower, upper); + assert_eq!(lower.as_str(), "image/svg+xml; charset=UTF-8; profile=web"); + assert_eq!(upper.as_str(), lower.as_str()); + assert_eq!(lower.to_string(), lower.as_str()); + let json = serde_json::to_string(&lower).unwrap(); + assert_eq!( + serde_json::from_str::<RadrootsBlossomMediaType>(&json).unwrap(), + lower + ); + } + + #[test] + fn media_type_rejects_invalid_values_and_types() { + for value in [ + "", + "image", + "image/", + "*/*", + "image/*", + "image/png; profile=a; PROFILE=b", + " image/png", + "image/png ", + "image/png\n", + ] { + assert_eq!( + RadrootsBlossomMediaType::parse(value), + Err(RadrootsBlossomError::InvalidMediaType) + ); + } + assert!(serde_json::from_str::<RadrootsBlossomMediaType>("42").is_err()); + } + + #[test] + fn descriptor_requires_extension_and_matching_url_hash() { + let hash = RadrootsBlossomSha256::from_hex(HELLO_HASH).unwrap(); + let media_type = RadrootsBlossomMediaType::parse("text/plain").unwrap(); + let no_extension = + RadrootsBlossomBlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}")) + .unwrap(); + assert_eq!( + RadrootsBlossomBlobDescriptor::new(no_extension, hash, 5, media_type.clone(), 1), + Err(RadrootsBlossomError::DescriptorExtensionRequired) + ); + let wrong_hash = RadrootsBlossomSha256::digest(b"wrong"); + let url = + RadrootsBlossomBlobUrl::parse(&format!("https://cdn.example.com/{HELLO_HASH}.txt")) + .unwrap(); + assert_eq!( + RadrootsBlossomBlobDescriptor::new(url, wrong_hash, 5, media_type, 1), + Err(RadrootsBlossomError::DescriptorHashMismatch) + ); + } + + #[test] + fn descriptor_serde_roundtrip_tolerates_extension_fields() { + let raw = format!( + r#"{{"url":"https://cdn.example.com/{HELLO_HASH}.txt","sha256":"{HELLO_HASH}","size":5,"type":"text/plain","uploaded":1725105921,"magnet":"magnet:?xt=urn:test"}}"# + ); + let parsed: RadrootsBlossomBlobDescriptor = serde_json::from_str(&raw).unwrap(); + assert_eq!(parsed.url().hash_path().hash().to_string(), HELLO_HASH); + assert_eq!(parsed.sha256().to_string(), HELLO_HASH); + assert_eq!(parsed.size(), 5); + assert_eq!(parsed.media_type().as_str(), "text/plain"); + assert_eq!(parsed.uploaded(), 1_725_105_921); + let encoded = serde_json::to_value(&parsed).unwrap(); + assert_eq!(encoded["type"], "text/plain"); + assert!(encoded.get("magnet").is_none()); + } + + #[test] + fn descriptor_deserialize_revalidates_invariants() { + let wrong_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let raw = format!( + r#"{{"url":"https://cdn.example.com/{HELLO_HASH}.txt","sha256":"{wrong_hash}","size":5,"type":"text/plain","uploaded":1}}"# + ); + assert!(serde_json::from_str::<RadrootsBlossomBlobDescriptor>(&raw).is_err()); + } + + #[test] + fn byte_verified_descriptor_requires_approved_url_size_type_and_hash() { + let media_type = RadrootsBlossomMediaType::parse("text/plain").unwrap(); + let commitment = RadrootsBlossomByteCommitment::from_bytes(b"hello", media_type.clone()); + assert_eq!(commitment.sha256().to_string(), HELLO_HASH); + assert_eq!(commitment.size(), 5); + assert_eq!(commitment.media_type(), &media_type); + let approved = descriptor("https://cdn.example.com", b"hello", "text/plain") + .approve_reference() + .unwrap(); + assert_eq!( + approved.url().as_str(), + approved.descriptor().url().as_str() + ); + let verified = approved.clone().verify_commitment(&commitment).unwrap(); + assert_eq!(verified.url().as_str(), approved.url().as_str()); + assert_eq!(verified.descriptor(), approved.descriptor()); + assert_eq!(verified.sha256().to_string(), HELLO_HASH); + assert_eq!(verified.size(), 5); + assert_eq!(verified.media_type(), &media_type); + assert_eq!(verified.clone().into_descriptor(), *approved.descriptor()); + assert_eq!(approved.clone().into_descriptor(), *approved.descriptor()); + + assert_eq!( + descriptor("https://cdn.example.com", b"hello", "text/plain") + .approve_reference() + .unwrap() + .verify_bytes(b"hell", &media_type), + Err(RadrootsBlossomError::BlobSizeMismatch { + expected: 5, + actual: 4, + }) + ); + let image_type = RadrootsBlossomMediaType::parse("image/png").unwrap(); + assert_eq!( + descriptor("https://cdn.example.com", b"hello", "text/plain") + .approve_reference() + .unwrap() + .verify_bytes(b"hello", &image_type), + Err(RadrootsBlossomError::BlobMediaTypeMismatch) + ); + let hash_mismatch = RadrootsBlossomBlobDescriptor::new( + RadrootsBlossomBlobUrl::parse(&format!( + "https://cdn.example.com/{}.txt", + RadrootsBlossomSha256::digest(b"world") + )) + .unwrap(), + RadrootsBlossomSha256::digest(b"world"), + 5, + media_type.clone(), + 1, + ) + .unwrap() + .approve_reference() + .unwrap(); + assert_eq!( + hash_mismatch.verify_bytes(b"hello", &media_type), + Err(RadrootsBlossomError::BlobHashMismatch) + ); + } + + #[test] + fn empty_blob_can_be_verified_with_explicit_default_media_type() { + let media_type = RadrootsBlossomMediaType::parse("application/octet-stream").unwrap(); + let verified = descriptor("http://localhost:3000", b"", "application/octet-stream") + .approve_reference() + .unwrap() + .verify_bytes(b"", &media_type) + .unwrap(); + assert_eq!(verified.size(), 0); + } + + #[test] + fn public_http_descriptor_cannot_advance_to_approved() { + assert_eq!( + descriptor("http://cdn.example.com", b"hello", "text/plain").approve_reference(), + Err(RadrootsBlossomError::InsecureBlobUrl) + ); + } +} diff --git a/crates/blossom/src/error.rs b/crates/blossom/src/error.rs @@ -0,0 +1,118 @@ +use core::fmt; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum RadrootsBlossomError { + InvalidSha256, + InvalidFileExtension, + InvalidHashPath, + InvalidBlobUrl, + UnsupportedBlobUrlScheme, + BlobUrlCredentialsForbidden, + BlobUrlQueryForbidden, + BlobUrlFragmentForbidden, + InsecureBlobUrl, + DescriptorExtensionRequired, + DescriptorHashMismatch, + InvalidMediaType, + BlobHashMismatch, + BlobSizeMismatch { expected: u64, actual: u64 }, + BlobMediaTypeMismatch, +} + +impl RadrootsBlossomError { + pub const fn code(&self) -> &'static str { + match self { + Self::InvalidSha256 => "invalid_sha256", + Self::InvalidFileExtension => "invalid_file_extension", + Self::InvalidHashPath => "invalid_hash_path", + Self::InvalidBlobUrl => "invalid_blob_url", + Self::UnsupportedBlobUrlScheme => "unsupported_blob_url_scheme", + Self::BlobUrlCredentialsForbidden => "blob_url_credentials_forbidden", + Self::BlobUrlQueryForbidden => "blob_url_query_forbidden", + Self::BlobUrlFragmentForbidden => "blob_url_fragment_forbidden", + Self::InsecureBlobUrl => "insecure_blob_url", + Self::DescriptorExtensionRequired => "descriptor_extension_required", + Self::DescriptorHashMismatch => "descriptor_hash_mismatch", + Self::InvalidMediaType => "invalid_media_type", + Self::BlobHashMismatch => "blob_hash_mismatch", + Self::BlobSizeMismatch { .. } => "blob_size_mismatch", + Self::BlobMediaTypeMismatch => "blob_media_type_mismatch", + } + } +} + +impl fmt::Display for RadrootsBlossomError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidSha256 => { + f.write_str("sha256 must be 64 lowercase hexadecimal characters") + } + Self::InvalidFileExtension => f.write_str("invalid Blossom file extension"), + Self::InvalidHashPath => f.write_str("invalid Blossom root hash path"), + Self::InvalidBlobUrl => f.write_str("invalid Blossom blob URL"), + Self::UnsupportedBlobUrlScheme => { + f.write_str("Blossom blob URL scheme must be http or https") + } + Self::BlobUrlCredentialsForbidden => { + f.write_str("Blossom blob URL credentials are forbidden") + } + Self::BlobUrlQueryForbidden => f.write_str("Blossom blob URL query is forbidden"), + Self::BlobUrlFragmentForbidden => f.write_str("Blossom blob URL fragment is forbidden"), + Self::InsecureBlobUrl => { + f.write_str("Radroots blob references require HTTPS or loopback HTTP") + } + Self::DescriptorExtensionRequired => { + f.write_str("BUD-02 descriptor URL requires a file extension") + } + Self::DescriptorHashMismatch => { + f.write_str("descriptor URL hash does not match descriptor sha256") + } + Self::InvalidMediaType => f.write_str("invalid media type"), + Self::BlobHashMismatch => f.write_str("blob bytes do not match descriptor sha256"), + Self::BlobSizeMismatch { expected, actual } => { + write!(f, "blob size mismatch: expected {expected}, got {actual}") + } + Self::BlobMediaTypeMismatch => { + f.write_str("descriptor media type does not match the approved media type") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for RadrootsBlossomError {} + +#[cfg(test)] +mod tests { + use super::RadrootsBlossomError; + use alloc::format; + + #[test] + fn error_codes_and_messages_are_stable() { + let errors = [ + RadrootsBlossomError::InvalidSha256, + RadrootsBlossomError::InvalidFileExtension, + RadrootsBlossomError::InvalidHashPath, + RadrootsBlossomError::InvalidBlobUrl, + RadrootsBlossomError::UnsupportedBlobUrlScheme, + RadrootsBlossomError::BlobUrlCredentialsForbidden, + RadrootsBlossomError::BlobUrlQueryForbidden, + RadrootsBlossomError::BlobUrlFragmentForbidden, + RadrootsBlossomError::InsecureBlobUrl, + RadrootsBlossomError::DescriptorExtensionRequired, + RadrootsBlossomError::DescriptorHashMismatch, + RadrootsBlossomError::InvalidMediaType, + RadrootsBlossomError::BlobHashMismatch, + RadrootsBlossomError::BlobSizeMismatch { + expected: 1, + actual: 2, + }, + RadrootsBlossomError::BlobMediaTypeMismatch, + ]; + for error in errors { + assert!(!error.code().is_empty()); + assert!(!format!("{error}").is_empty()); + } + } +} diff --git a/crates/blossom/src/hash.rs b/crates/blossom/src/hash.rs @@ -0,0 +1,354 @@ +use alloc::string::{String, ToString}; +use core::{fmt, str::FromStr}; +use sha2::{Digest, Sha256}; + +use crate::RadrootsBlossomError; + +const SHA256_BYTES: usize = 32; +const SHA256_HEX_LENGTH: usize = SHA256_BYTES * 2; +const LOWER_HEX: &[u8; 16] = b"0123456789abcdef"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsBlossomSha256([u8; SHA256_BYTES]); + +impl RadrootsBlossomSha256 { + pub fn digest(bytes: &[u8]) -> Self { + let digest = Sha256::digest(bytes); + let mut value = [0_u8; SHA256_BYTES]; + value.copy_from_slice(&digest); + Self(value) + } + + pub fn from_hex(value: &str) -> Result<Self, RadrootsBlossomError> { + if value.len() != SHA256_HEX_LENGTH { + return Err(RadrootsBlossomError::InvalidSha256); + } + + let bytes = value.as_bytes(); + let mut decoded = [0_u8; SHA256_BYTES]; + for (index, output) in decoded.iter_mut().enumerate() { + let high = decode_nibble(bytes[index * 2])?; + let low = decode_nibble(bytes[index * 2 + 1])?; + *output = (high << 4) | low; + } + Ok(Self(decoded)) + } + + pub const fn as_bytes(&self) -> &[u8; SHA256_BYTES] { + &self.0 + } + + pub fn to_hex(self) -> String { + let mut output = String::with_capacity(SHA256_HEX_LENGTH); + for byte in self.0 { + output.push(char::from(LOWER_HEX[usize::from(byte >> 4)])); + output.push(char::from(LOWER_HEX[usize::from(byte & 0x0f)])); + } + output + } +} + +impl fmt::Display for RadrootsBlossomSha256 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_hex()) + } +} + +impl FromStr for RadrootsBlossomSha256 { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::from_hex(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for RadrootsBlossomSha256 { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_hex()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsBlossomSha256 { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::from_hex(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsBlossomFileExtension(String); + +impl RadrootsBlossomFileExtension { + pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { + if value.is_empty() + || value.split('.').any(str::is_empty) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(RadrootsBlossomError::InvalidFileExtension); + } + Ok(Self(value.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for RadrootsBlossomFileExtension { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsBlossomFileExtension { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for RadrootsBlossomFileExtension { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsBlossomFileExtension { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RadrootsBlossomHashPath { + hash: RadrootsBlossomSha256, + extension: Option<RadrootsBlossomFileExtension>, +} + +impl RadrootsBlossomHashPath { + pub fn new( + hash: RadrootsBlossomSha256, + extension: Option<RadrootsBlossomFileExtension>, + ) -> Self { + Self { hash, extension } + } + + pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { + let segment = value + .strip_prefix('/') + .ok_or(RadrootsBlossomError::InvalidHashPath)?; + if segment.contains('/') + || segment.contains('\\') + || segment.contains('%') + || segment.contains('?') + || segment.contains('#') + { + return Err(RadrootsBlossomError::InvalidHashPath); + } + + let hash = segment + .get(..SHA256_HEX_LENGTH) + .ok_or(RadrootsBlossomError::InvalidHashPath) + .and_then(RadrootsBlossomSha256::from_hex)?; + let suffix = &segment[SHA256_HEX_LENGTH..]; + let extension = if suffix.is_empty() { + None + } else { + let value = suffix + .strip_prefix('.') + .ok_or(RadrootsBlossomError::InvalidHashPath)?; + Some(RadrootsBlossomFileExtension::parse(value)?) + }; + Ok(Self::new(hash, extension)) + } + + pub const fn hash(&self) -> RadrootsBlossomSha256 { + self.hash + } + + pub fn extension(&self) -> Option<&RadrootsBlossomFileExtension> { + self.extension.as_ref() + } + + pub fn to_path(&self) -> String { + let mut value = String::with_capacity( + 1 + SHA256_HEX_LENGTH + + self + .extension + .as_ref() + .map_or(0, |extension| 1 + extension.as_str().len()), + ); + value.push('/'); + value.push_str(&self.hash.to_hex()); + if let Some(extension) = &self.extension { + value.push('.'); + value.push_str(extension.as_str()); + } + value + } +} + +impl fmt::Display for RadrootsBlossomHashPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_path()) + } +} + +impl FromStr for RadrootsBlossomHashPath { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for RadrootsBlossomHashPath { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_path()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsBlossomHashPath { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +const fn decode_nibble(byte: u8) -> Result<u8, RadrootsBlossomError> { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(RadrootsBlossomError::InvalidSha256), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + + const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + #[test] + fn hash_round_trips_known_digest_and_serde() { + let hash = RadrootsBlossomSha256::digest(b""); + assert_eq!(hash.to_string(), EMPTY_SHA256); + assert_eq!(hash.as_bytes().len(), SHA256_BYTES); + assert_eq!(RadrootsBlossomSha256::from_str(EMPTY_SHA256), Ok(hash)); + let json = serde_json::to_string(&hash).unwrap(); + assert_eq!( + serde_json::from_str::<RadrootsBlossomSha256>(&json).unwrap(), + hash + ); + } + + #[test] + fn hash_rejects_wrong_length_case_and_alphabet() { + for value in [ + "", + "0", + &"a".repeat(63), + &"a".repeat(65), + &"A".repeat(64), + &"z".repeat(64), + ] { + assert_eq!( + RadrootsBlossomSha256::from_hex(value), + Err(RadrootsBlossomError::InvalidSha256) + ); + } + assert!(serde_json::from_str::<RadrootsBlossomSha256>("12").is_err()); + } + + #[test] + fn extension_accepts_simple_and_compound_values() { + for value in ["png", "tar.gz", "x-custom_2", "PNG"] { + let extension = value.parse::<RadrootsBlossomFileExtension>().unwrap(); + assert_eq!(extension.as_str(), value); + assert_eq!(extension.to_string(), value); + let json = serde_json::to_string(&extension).unwrap(); + assert_eq!( + serde_json::from_str::<RadrootsBlossomFileExtension>(&json).unwrap(), + extension + ); + } + } + + #[test] + fn extension_rejects_invalid_values() { + for value in ["", ".png", "png.", "tar..gz", "a/b", "a b", "café"] { + assert_eq!( + RadrootsBlossomFileExtension::parse(value), + Err(RadrootsBlossomError::InvalidFileExtension) + ); + } + assert!(serde_json::from_str::<RadrootsBlossomFileExtension>("false").is_err()); + } + + #[test] + fn hash_path_round_trips_with_and_without_extension() { + let bare = format!("/{EMPTY_SHA256}"); + let parsed = RadrootsBlossomHashPath::parse(&bare).unwrap(); + assert_eq!(parsed.hash().to_string(), EMPTY_SHA256); + assert_eq!(parsed.extension(), None); + assert_eq!(parsed.to_path(), bare); + + let extended = format!("/{EMPTY_SHA256}.webp"); + let parsed = RadrootsBlossomHashPath::from_str(&extended).unwrap(); + assert_eq!(parsed.extension().unwrap().as_str(), "webp"); + assert_eq!(parsed.to_string(), extended); + let json = serde_json::to_string(&parsed).unwrap(); + assert_eq!( + serde_json::from_str::<RadrootsBlossomHashPath>(&json).unwrap(), + parsed + ); + } + + #[test] + fn hash_path_rejects_non_root_and_ambiguous_paths() { + for path in [ + EMPTY_SHA256, + "/", + "/abc", + &format!("/{EMPTY_SHA256}/x"), + &format!("/{EMPTY_SHA256}%2epng"), + &format!("/{EMPTY_SHA256}.png?download=1"), + &format!("/{EMPTY_SHA256}.png#media"), + &format!("/{EMPTY_SHA256}\\x"), + &format!("/{EMPTY_SHA256}png"), + &format!("/{EMPTY_SHA256}."), + ] { + assert!(RadrootsBlossomHashPath::parse(path).is_err(), "{path}"); + } + assert!(serde_json::from_str::<RadrootsBlossomHashPath>("null").is_err()); + } +} diff --git a/crates/blossom/src/lib.rs b/crates/blossom/src/lib.rs @@ -0,0 +1,20 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![cfg_attr(coverage_nightly, feature(coverage_attribute))] +#![forbid(unsafe_code)] + +extern crate alloc; + +pub mod descriptor; +pub mod error; +pub mod hash; +pub mod url; + +pub use descriptor::{ + RadrootsBlossomApprovedDescriptor, RadrootsBlossomBlobDescriptor, + RadrootsBlossomByteCommitment, RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomMediaType, +}; +pub use error::RadrootsBlossomError; +pub use hash::{RadrootsBlossomFileExtension, RadrootsBlossomHashPath, RadrootsBlossomSha256}; +pub use url::{RadrootsBlossomApprovedBlobUrl, RadrootsBlossomBlobUrl}; + +pub const RADROOTS_BLOSSOM_PROTOCOL_COMMIT: &str = "b5bd2801d1763aa635fc8fea7a76597e0eb18990"; diff --git a/crates/blossom/src/url.rs b/crates/blossom/src/url.rs @@ -0,0 +1,380 @@ +#[cfg(feature = "serde")] +use alloc::string::String; +use alloc::string::ToString; +use core::{fmt, str::FromStr}; +use url_nostd::{Host, Url}; + +use crate::{RadrootsBlossomError, RadrootsBlossomHashPath}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsBlossomBlobUrl { + url: Url, + hash_path: RadrootsBlossomHashPath, +} + +impl RadrootsBlossomBlobUrl { + pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { + if !value.contains("://") + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err(RadrootsBlossomError::InvalidBlobUrl); + } + let url = Url::parse(value).map_err(|_| RadrootsBlossomError::InvalidBlobUrl)?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err(RadrootsBlossomError::UnsupportedBlobUrlScheme); + } + if raw_authority(value).contains('@') { + return Err(RadrootsBlossomError::BlobUrlCredentialsForbidden); + } + if url.query().is_some() { + return Err(RadrootsBlossomError::BlobUrlQueryForbidden); + } + if url.fragment().is_some() { + return Err(RadrootsBlossomError::BlobUrlFragmentForbidden); + } + if value.contains('\\') + || value.contains('%') + || value.contains("/./") + || value.contains("/../") + { + return Err(RadrootsBlossomError::InvalidBlobUrl); + } + validate_authority(value, &url)?; + let hash_path = RadrootsBlossomHashPath::parse(url.path())?; + Ok(Self { url, hash_path }) + } + + pub fn as_str(&self) -> &str { + self.url.as_str() + } + + pub fn scheme(&self) -> &str { + self.url.scheme() + } + + pub fn host(&self) -> &str { + self.url + .host_str() + .expect("validated HTTP and HTTPS URLs always have a host") + } + + pub fn port(&self) -> Option<u16> { + self.url.port() + } + + pub fn hash_path(&self) -> &RadrootsBlossomHashPath { + &self.hash_path + } + + pub fn is_https(&self) -> bool { + self.url.scheme() == "https" + } + + pub fn is_loopback_http(&self) -> bool { + self.url.scheme() == "http" && self.url.host().is_some_and(host_is_loopback) + } + + pub fn approve(self) -> Result<RadrootsBlossomApprovedBlobUrl, RadrootsBlossomError> { + if !self.is_https() && !self.is_loopback_http() { + return Err(RadrootsBlossomError::InsecureBlobUrl); + } + Ok(RadrootsBlossomApprovedBlobUrl(self)) + } +} + +impl fmt::Display for RadrootsBlossomBlobUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for RadrootsBlossomBlobUrl { + type Err = RadrootsBlossomError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for RadrootsBlossomBlobUrl { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for RadrootsBlossomBlobUrl { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsBlossomApprovedBlobUrl(RadrootsBlossomBlobUrl); + +impl RadrootsBlossomApprovedBlobUrl { + pub fn as_blob_url(&self) -> &RadrootsBlossomBlobUrl { + &self.0 + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_blob_url(self) -> RadrootsBlossomBlobUrl { + self.0 + } +} + +impl fmt::Display for RadrootsBlossomApprovedBlobUrl { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +fn host_is_loopback(host: Host<&str>) -> bool { + match host { + Host::Domain(domain) => { + domain == "localhost" + || domain.strip_suffix(".localhost").is_some_and(|prefix| { + !prefix.is_empty() && prefix.split('.').all(|label| !label.is_empty()) + }) + } + Host::Ipv4(address) => address.octets()[0] == 127, + Host::Ipv6(address) => address.segments() == [0, 0, 0, 0, 0, 0, 0, 1], + } +} + +fn validate_authority(value: &str, url: &Url) -> Result<(), RadrootsBlossomError> { + let raw_host = raw_authority_host(value); + if let Some(port) = raw_authority_port(value) { + match port.parse::<u16>() { + Ok(1..) => {} + Ok(0) | Err(_) => return Err(RadrootsBlossomError::InvalidBlobUrl), + } + } + if let Some(Host::Ipv4(address)) = url.host() + && raw_host != address.to_string() + { + return Err(RadrootsBlossomError::InvalidBlobUrl); + } + Ok(()) +} + +fn raw_authority(value: &str) -> &str { + let (_, remainder) = value + .split_once("://") + .expect("blob URL parser requires an explicit scheme delimiter"); + let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len()); + &remainder[..authority_end] +} + +fn raw_authority_host(value: &str) -> &str { + let host_and_port = raw_authority(value); + if let Some(bracketed) = host_and_port.strip_prefix('[') { + return bracketed + .split_once(']') + .expect("validated bracketed URL host must close") + .0; + } + host_and_port + .rsplit_once(':') + .map_or(host_and_port, |(host, _)| host) +} + +fn raw_authority_port(value: &str) -> Option<&str> { + let host_and_port = raw_authority(value); + if let Some(bracketed) = host_and_port.strip_prefix('[') { + let (_, suffix) = bracketed + .split_once(']') + .expect("validated bracketed URL host must close"); + return suffix.strip_prefix(':'); + } + host_and_port.rsplit_once(':').map(|(_, port)| port) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{format, string::ToString}; + + const HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + + fn url(origin: &str) -> String { + format!("{origin}/{HASH}.txt") + } + + #[test] + fn https_reference_is_structural_and_approved() { + let parsed = RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com")).unwrap(); + assert!(parsed.is_https()); + assert!(!parsed.is_loopback_http()); + assert_eq!(parsed.hash_path().hash().to_string(), HASH); + assert_eq!(parsed.hash_path().extension().unwrap().as_str(), "txt"); + assert_eq!(parsed.scheme(), "https"); + assert_eq!(parsed.host(), "cdn.example.com"); + assert_eq!(parsed.port(), None); + assert_eq!(parsed.clone().approve().unwrap().as_str(), parsed.as_str()); + assert_eq!(parsed.to_string(), url("https://cdn.example.com")); + } + + #[test] + fn loopback_http_reference_set_is_approved() { + for origin in [ + "http://localhost:3000", + "http://media.localhost", + "http://127.0.0.1", + "http://127.255.10.9:8080", + "http://[::1]:3000", + "http://[0:0:0:0:0:0:0:1]", + ] { + let parsed = RadrootsBlossomBlobUrl::parse(&url(origin)).unwrap(); + assert!(parsed.is_loopback_http(), "{origin}"); + let canonical = parsed.as_str().to_string(); + let approved = parsed.approve().unwrap(); + assert_eq!(approved.as_blob_url().as_str(), canonical); + assert_eq!(approved.clone().into_blob_url().as_str(), canonical); + assert_eq!(approved.to_string(), canonical); + } + } + + #[test] + fn public_and_private_http_are_structural_but_not_approved() { + for origin in [ + "http://cdn.example.com", + "http://localhost.example.com", + "http://192.168.1.2", + "http://10.0.0.2", + "http://[::]", + "http://.localhost", + "http://a..localhost", + ] { + let parsed = RadrootsBlossomBlobUrl::parse(&url(origin)).unwrap(); + assert!(!parsed.is_https()); + assert!(!parsed.is_loopback_http()); + assert_eq!( + parsed.approve(), + Err(RadrootsBlossomError::InsecureBlobUrl), + "{origin}" + ); + } + } + + #[test] + fn blob_url_rejects_scheme_host_credentials_query_and_fragment() { + let cases = [ + ( + url("ftp://cdn.example.com"), + RadrootsBlossomError::UnsupportedBlobUrlScheme, + ), + ( + format!("https://user@cdn.example.com/{HASH}.txt"), + RadrootsBlossomError::BlobUrlCredentialsForbidden, + ), + ( + format!("https://:password@cdn.example.com/{HASH}.txt"), + RadrootsBlossomError::BlobUrlCredentialsForbidden, + ), + ( + format!("https://@cdn.example.com/{HASH}.txt"), + RadrootsBlossomError::BlobUrlCredentialsForbidden, + ), + ( + format!("https://:@cdn.example.com/{HASH}.txt"), + RadrootsBlossomError::BlobUrlCredentialsForbidden, + ), + ( + format!("https://cdn.example.com/{HASH}.txt?a=1"), + RadrootsBlossomError::BlobUrlQueryForbidden, + ), + ( + format!("https://cdn.example.com/{HASH}.txt#x"), + RadrootsBlossomError::BlobUrlFragmentForbidden, + ), + ]; + for (value, expected) in cases { + assert_eq!( + RadrootsBlossomBlobUrl::parse(&value), + Err(expected), + "{value}" + ); + } + } + + #[test] + fn blob_url_rejects_non_root_encoded_and_traversal_paths() { + for value in [ + format!("https://cdn.example.com/x/{HASH}.txt"), + format!("https://cdn.example.com/{HASH}.txt/x"), + format!("https://cdn.example.com/{HASH}%2etxt"), + format!("https://cdn.example.com/x/./../{HASH}.txt"), + format!("https://cdn.example.com/{HASH}/../{HASH}.txt"), + format!("https://cdn.example.com/{HASH}\\x"), + ] { + assert!(RadrootsBlossomBlobUrl::parse(&value).is_err(), "{value}"); + } + } + + #[test] + fn blob_url_serde_revalidates_structure() { + let parsed = RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com")).unwrap(); + let json = serde_json::to_string(&parsed).unwrap(); + assert_eq!( + serde_json::from_str::<RadrootsBlossomBlobUrl>(&json).unwrap(), + parsed + ); + assert!(serde_json::from_str::<RadrootsBlossomBlobUrl>("false").is_err()); + assert!( + serde_json::from_str::<RadrootsBlossomBlobUrl>( + "\"https://cdn.example.com/not-a-hash.png\"" + ) + .is_err() + ); + } + + #[test] + fn malformed_url_is_rejected() { + assert_eq!( + RadrootsBlossomBlobUrl::from_str("not a url"), + Err(RadrootsBlossomError::InvalidBlobUrl) + ); + assert!(RadrootsBlossomBlobUrl::parse(&format!("https:///{HASH}.txt")).is_err()); + assert!(RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com:0")).is_err()); + assert!(RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com:00")).is_err()); + assert!(RadrootsBlossomBlobUrl::parse(&url("https://cdn.example.com:")).is_err()); + for value in [ + format!(" https://cdn.example.com/{HASH}.txt"), + format!("https://cdn.example.com/{HASH}.txt\n"), + format!("https://cdn.example.com/{HASH}.txt\t"), + format!("https://cdn.example.com/{HASH}.txt\u{7f}"), + ] { + assert_eq!( + RadrootsBlossomBlobUrl::parse(&value), + Err(RadrootsBlossomError::InvalidBlobUrl), + "{value:?}" + ); + } + for origin in [ + "http://0177.0.0.1", + "http://0x7f.0.0.1", + "http://127.1", + "https://2130706433", + ] { + assert_eq!( + RadrootsBlossomBlobUrl::parse(&url(origin)), + Err(RadrootsBlossomError::InvalidBlobUrl), + "{origin}" + ); + } + } +} diff --git a/crates/blossom/tests/conformance.rs b/crates/blossom/tests/conformance.rs @@ -0,0 +1,387 @@ +use radroots_blossom::{ + RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomHashPath, + RadrootsBlossomMediaType, RadrootsBlossomSha256, +}; +use serde::Deserialize; +use serde_json::Value; +use std::{borrow::Cow, fs, path::Path}; + +const PACKAGED_VECTORS: &str = include_str!("fixtures/hash_path_and_descriptor.v1.json"); +const WORKSPACE_VECTOR_PATH: &str = + "../../contracts/conformance/vectors/blossom/hash_path_and_descriptor.v1.json"; +const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml"; + +#[derive(Debug, Deserialize)] +struct Suite { + suite: String, + contract_version: String, + vectors: Vec<Vector>, +} + +#[derive(Debug, Deserialize)] +struct Vector { + id: String, + kind: String, + input: Value, + expected: Value, +} + +#[test] +fn checked_in_vectors_execute_against_public_api() { + let vectors = conformance_vectors(); + let suite: Suite = serde_json::from_str(&vectors).expect("Blossom vectors must parse"); + assert_eq!(suite.suite, "blossom_hash_path_and_descriptor"); + assert_eq!(suite.contract_version, "0.1.0"); + assert!(!suite.vectors.is_empty()); + + for vector in &suite.vectors { + execute(vector); + } +} + +fn conformance_vectors() -> Cow<'static, str> { + let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH); + match fs::read_to_string(&workspace_path) { + Ok(canonical) => { + assert_eq!( + canonical, + PACKAGED_VECTORS, + "packaged Blossom vectors must match {}", + workspace_path.display() + ); + Cow::Owned(canonical) + } + Err(error) + if error.kind() == std::io::ErrorKind::NotFound + && !Path::new(env!("CARGO_MANIFEST_DIR")) + .join(WORKSPACE_CONTRACT_MARKER_PATH) + .is_file() => + { + Cow::Borrowed(PACKAGED_VECTORS) + } + Err(error) => panic!("failed to read {}: {error}", workspace_path.display()), + } +} + +fn execute(vector: &Vector) { + match vector.kind.as_str() { + "blossom.sha256.digest" => sha256_digest(vector), + "blossom.sha256.parse.valid" => sha256_parse_valid(vector), + "blossom.sha256.parse.invalid" => sha256_parse_invalid(vector), + "blossom.hash_path.parse.valid" => hash_path_parse_valid(vector), + "blossom.hash_path.parse.invalid" => hash_path_parse_invalid(vector), + "blossom.blob_url.parse.valid" => blob_url_parse_valid(vector), + "blossom.blob_url.parse.invalid" => blob_url_parse_invalid(vector), + "blossom.reference_policy.valid" => reference_policy_valid(vector), + "blossom.reference_policy.invalid" => reference_policy_invalid(vector), + "blossom.media_type.parse.valid" => media_type_parse_valid(vector), + "blossom.media_type.parse.invalid" => media_type_parse_invalid(vector), + "blossom.descriptor.parse.valid" => descriptor_parse_valid(vector), + "blossom.descriptor.parse.invalid" => descriptor_parse_invalid(vector), + "blossom.descriptor.approve_reference.valid" => descriptor_approval_valid(vector), + "blossom.descriptor.approve_reference.invalid" => descriptor_approval_invalid(vector), + "blossom.descriptor.verify_bytes.valid" => descriptor_verify_valid(vector), + "blossom.descriptor.verify_bytes.invalid" => descriptor_verify_invalid(vector), + kind => panic!("{} uses unsupported vector kind {kind}", vector.id), + } +} + +fn sha256_digest(vector: &Vector) { + let bytes = input_bytes(vector); + assert_eq!( + RadrootsBlossomSha256::digest(&bytes).to_string(), + expected_str(vector, "sha256"), + "{}", + vector.id + ); +} + +fn sha256_parse_valid(vector: &Vector) { + let parsed = RadrootsBlossomSha256::from_hex(input_str(vector, "sha256")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + parsed.to_string(), + expected_str(vector, "sha256"), + "{}", + vector.id + ); +} + +fn sha256_parse_invalid(vector: &Vector) { + let error = RadrootsBlossomSha256::from_hex(input_str(vector, "sha256")) + .expect_err("invalid SHA-256 vector must fail"); + assert_error(vector, error.code()); +} + +fn hash_path_parse_valid(vector: &Vector) { + let parsed = RadrootsBlossomHashPath::parse(input_str(vector, "path")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + parsed.hash().to_string(), + expected_str(vector, "sha256"), + "{}", + vector.id + ); + assert_eq!( + parsed.extension().map(|extension| extension.as_str()), + optional_expected_str(vector, "extension"), + "{}", + vector.id + ); +} + +fn hash_path_parse_invalid(vector: &Vector) { + let error = RadrootsBlossomHashPath::parse(input_str(vector, "path")) + .expect_err("invalid hash-path vector must fail"); + assert_error(vector, error.code()); +} + +fn blob_url_parse_valid(vector: &Vector) { + let parsed = parse_blob_url(vector); + assert_eq!( + parsed.scheme(), + expected_str(vector, "scheme"), + "{}", + vector.id + ); + assert_eq!(parsed.host(), expected_str(vector, "host"), "{}", vector.id); + assert_eq!( + parsed.port().map(u64::from), + vector.expected.get("port").and_then(Value::as_u64), + "{}", + vector.id + ); + assert_eq!( + parsed.hash_path().hash().to_string(), + expected_str(vector, "sha256"), + "{}", + vector.id + ); + assert_eq!( + parsed + .hash_path() + .extension() + .map(|extension| extension.as_str()), + optional_expected_str(vector, "extension"), + "{}", + vector.id + ); +} + +fn blob_url_parse_invalid(vector: &Vector) { + let error = RadrootsBlossomBlobUrl::parse(input_str(vector, "url")) + .expect_err("invalid blob-URL vector must fail"); + assert_error(vector, error.code()); +} + +fn reference_policy_valid(vector: &Vector) { + let parsed = parse_blob_url(vector); + let transport = if parsed.is_https() { + "https" + } else if parsed.is_loopback_http() { + "loopback_http" + } else { + panic!("{} is not an approved reference", vector.id); + }; + let approved = parsed + .approve() + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!(vector.expected["approved"], true, "{}", vector.id); + assert_eq!( + transport, + expected_str(vector, "transport"), + "{}", + vector.id + ); + assert!(!approved.as_str().is_empty()); +} + +fn reference_policy_invalid(vector: &Vector) { + let error = parse_blob_url(vector) + .approve() + .expect_err("insecure reference vector must fail"); + assert_error(vector, error.code()); +} + +fn media_type_parse_valid(vector: &Vector) { + let parsed = RadrootsBlossomMediaType::parse(input_str(vector, "media_type")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); + assert_eq!( + parsed.as_str(), + expected_str(vector, "media_type"), + "{}", + vector.id + ); +} + +fn media_type_parse_invalid(vector: &Vector) { + let error = RadrootsBlossomMediaType::parse(input_str(vector, "media_type")) + .expect_err("invalid media-type vector must fail"); + assert_error(vector, error.code()); +} + +fn descriptor_parse_valid(vector: &Vector) { + let descriptor = parse_descriptor(vector); + let actual = serde_json::to_value(descriptor).expect("descriptor must serialize"); + assert_eq!(actual, vector.expected["descriptor"], "{}", vector.id); +} + +fn descriptor_parse_invalid(vector: &Vector) { + let input = &vector.input["descriptor"]; + serde_json::from_value::<RadrootsBlossomBlobDescriptor>(input.clone()) + .expect_err("invalid descriptor vector must fail"); + let code = classify_descriptor_input(input); + assert_error(vector, code); + if expected_error(vector) == "missing_descriptor_field" { + let field = expected_str(vector, "field"); + assert!(input.get(field).is_none(), "{} retains {field}", vector.id); + } +} + +fn descriptor_approval_valid(vector: &Vector) { + let approved = parse_descriptor(vector) + .approve_reference() + .unwrap_or_else(|error| panic!("{} approval failed: {error}", vector.id)); + assert_eq!( + approved.url().as_str(), + expected_str(vector, "approved_url"), + "{}", + vector.id + ); +} + +fn descriptor_approval_invalid(vector: &Vector) { + let error = parse_descriptor(vector) + .approve_reference() + .expect_err("invalid descriptor approval vector must fail"); + assert_error(vector, error.code()); +} + +fn descriptor_verify_valid(vector: &Vector) { + let bytes = input_bytes(vector); + let media_type = approved_media_type(vector); + let verified = parse_descriptor(vector) + .approve_reference() + .unwrap_or_else(|error| panic!("{} approval failed: {error}", vector.id)) + .verify_bytes(&bytes, &media_type) + .unwrap_or_else(|error| panic!("{} verification failed: {error}", vector.id)); + let expected = &vector.expected["verified"]; + assert_eq!( + verified.sha256().to_string(), + expected["sha256"].as_str().expect("expected sha256"), + "{}", + vector.id + ); + assert_eq!( + verified.size(), + expected["size"].as_u64().expect("expected size"), + "{}", + vector.id + ); + assert_eq!( + verified.media_type().as_str(), + expected["media_type"] + .as_str() + .expect("expected media_type"), + "{}", + vector.id + ); +} + +fn descriptor_verify_invalid(vector: &Vector) { + let bytes = input_bytes(vector); + let media_type = approved_media_type(vector); + let error = parse_descriptor(vector) + .approve_reference() + .unwrap_or_else(|error| panic!("{} approval failed: {error}", vector.id)) + .verify_bytes(&bytes, &media_type) + .expect_err("invalid descriptor verification vector must fail"); + assert_error(vector, error.code()); +} + +fn parse_blob_url(vector: &Vector) -> RadrootsBlossomBlobUrl { + RadrootsBlossomBlobUrl::parse(input_str(vector, "url")) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)) +} + +fn parse_descriptor(vector: &Vector) -> RadrootsBlossomBlobDescriptor { + serde_json::from_value(vector.input["descriptor"].clone()) + .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)) +} + +fn approved_media_type(vector: &Vector) -> RadrootsBlossomMediaType { + RadrootsBlossomMediaType::parse(input_str(vector, "approved_media_type")) + .unwrap_or_else(|error| panic!("{} media type failed: {error}", vector.id)) +} + +fn input_bytes(vector: &Vector) -> Vec<u8> { + hex::decode(input_str(vector, "bytes_hex")) + .unwrap_or_else(|error| panic!("{} bytes_hex failed: {error}", vector.id)) +} + +fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.input[field] + .as_str() + .unwrap_or_else(|| panic!("{} input.{field} must be a string", vector.id)) +} + +fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str { + vector.expected[field] + .as_str() + .unwrap_or_else(|| panic!("{} expected.{field} must be a string", vector.id)) +} + +fn optional_expected_str<'a>(vector: &'a Vector, field: &str) -> Option<&'a str> { + vector.expected.get(field).and_then(Value::as_str) +} + +fn expected_error(vector: &Vector) -> &str { + expected_str(vector, "error") +} + +fn assert_error(vector: &Vector, actual: &str) { + assert_eq!(actual, expected_error(vector), "{}", vector.id); +} + +fn classify_descriptor_input(input: &Value) -> &'static str { + for field in ["url", "sha256", "size", "type", "uploaded"] { + if input.get(field).is_none() { + return "missing_descriptor_field"; + } + } + + let url = match RadrootsBlossomBlobUrl::parse( + input["url"] + .as_str() + .expect("descriptor url must be a string"), + ) { + Ok(url) => url, + Err(error) => return error.code(), + }; + let sha256 = match RadrootsBlossomSha256::from_hex( + input["sha256"] + .as_str() + .expect("descriptor sha256 must be a string"), + ) { + Ok(sha256) => sha256, + Err(error) => return error.code(), + }; + let Some(size) = input["size"].as_u64() else { + return "invalid_descriptor_size"; + }; + let media_type = match RadrootsBlossomMediaType::parse( + input["type"] + .as_str() + .expect("descriptor type must be a string"), + ) { + Ok(media_type) => media_type, + Err(error) => return error.code(), + }; + let Some(uploaded) = input["uploaded"].as_u64() else { + return "invalid_descriptor_uploaded"; + }; + + RadrootsBlossomBlobDescriptor::new(url, sha256, size, media_type, uploaded) + .expect_err("invalid descriptor vector must fail structured construction") + .code() +} diff --git a/crates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json b/crates/blossom/tests/fixtures/hash_path_and_descriptor.v1.json @@ -0,0 +1,1273 @@ +{ + "suite": "blossom_hash_path_and_descriptor", + "contract_version": "0.1.0", + "vectors": [ + { + "id": "blossom_sha256_digest_empty_001", + "kind": "blossom.sha256.digest", + "input": { + "bytes_hex": "" + }, + "expected": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + { + "id": "blossom_sha256_digest_abc_002", + "kind": "blossom.sha256.digest", + "input": { + "bytes_hex": "616263" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + } + }, + { + "id": "blossom_sha256_digest_binary_003", + "kind": "blossom.sha256.digest", + "input": { + "bytes_hex": "89504e470d0a1a0a726164726f6f7473" + }, + "expected": { + "sha256": "a829dda89323e71a4575c1c45c52d65c3a0c87dd8ffcdab9ad12bbee57c00861" + } + }, + { + "id": "blossom_sha256_parse_valid_004", + "kind": "blossom.sha256.parse.valid", + "input": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + } + }, + { + "id": "blossom_sha256_parse_zero_valid_005", + "kind": "blossom.sha256.parse.valid", + "input": { + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "expected": { + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } + }, + { + "id": "blossom_sha256_parse_short_invalid_006", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "ba7816bf8f01cfea" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_sha256_parse_uppercase_invalid_007", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_sha256_parse_non_hex_invalid_008", + "kind": "blossom.sha256.parse.invalid", + "input": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ag" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_hash_path_without_extension_009", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "expected": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "extension": null + } + }, + { + "id": "blossom_hash_path_png_extension_010", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "png" + } + }, + { + "id": "blossom_hash_path_multi_extension_011", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.tar.gz" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "tar.gz" + } + }, + { + "id": "blossom_hash_path_extension_case_preserved_012", + "kind": "blossom.hash_path.parse.valid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.Photo_JPG-2" + }, + "expected": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "Photo_JPG-2" + } + }, + { + "id": "blossom_hash_path_missing_root_slash_013", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_empty_014", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_extra_segment_015", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad/png" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_uppercase_hash_016", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD.png" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_hash_path_empty_extension_017", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad." + }, + "expected": { + "error": "invalid_file_extension" + } + }, + { + "id": "blossom_hash_path_empty_extension_component_018", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.tar..gz" + }, + "expected": { + "error": "invalid_file_extension" + } + }, + { + "id": "blossom_hash_path_whitespace_extension_019", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.p ng" + }, + "expected": { + "error": "invalid_file_extension" + } + }, + { + "id": "blossom_hash_path_percent_encoded_extension_020", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.%70ng" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_hash_path_query_forbidden_021", + "kind": "blossom.hash_path.parse.invalid", + "input": { + "path": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png?download=1" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_blob_url_https_canonical_authority_022", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "HTTPS://MEDIA.EXAMPLE/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.PNG" + }, + "expected": { + "scheme": "https", + "host": "media.example", + "port": null, + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "PNG" + } + }, + { + "id": "blossom_blob_url_public_http_structural_023", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "scheme": "http", + "host": "media.example", + "port": null, + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "png" + } + }, + { + "id": "blossom_blob_url_https_port_without_extension_024", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "https://media.example:8443/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "expected": { + "scheme": "https", + "host": "media.example", + "port": 8443, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "extension": null + } + }, + { + "id": "blossom_blob_url_bracketed_ipv6_025", + "kind": "blossom.blob_url.parse.valid", + "input": { + "url": "http://[::1]:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.bin" + }, + "expected": { + "scheme": "http", + "host": "[::1]", + "port": 21100, + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "extension": "bin" + } + }, + { + "id": "blossom_blob_url_relative_invalid_026", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_unsupported_scheme_027", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "ftp://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "unsupported_blob_url_scheme" + } + }, + { + "id": "blossom_blob_url_missing_authority_028", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_credentials_forbidden_029", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://user:password@media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "blob_url_credentials_forbidden" + } + }, + { + "id": "blossom_blob_url_empty_userinfo_forbidden_029b", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://@media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "blob_url_credentials_forbidden" + } + }, + { + "id": "blossom_blob_url_empty_userinfo_separator_forbidden_029c", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://:@media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "blob_url_credentials_forbidden" + } + }, + { + "id": "blossom_blob_url_query_forbidden_030", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png?download=1" + }, + "expected": { + "error": "blob_url_query_forbidden" + } + }, + { + "id": "blossom_blob_url_fragment_forbidden_031", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png#preview" + }, + "expected": { + "error": "blob_url_fragment_forbidden" + } + }, + { + "id": "blossom_blob_url_extra_path_segment_032", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/blobs/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_hash_path" + } + }, + { + "id": "blossom_blob_url_port_out_of_range_033", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example:65536/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_uppercase_hash_034", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD.png" + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_blob_url_percent_encoded_extension_035", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.%70ng" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_unbracketed_ipv6_036", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "http://::1/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.bin" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_leading_whitespace_rejected_036b", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": " https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_embedded_newline_rejected_036c", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/\nba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_blob_url_trailing_tab_rejected_036d", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png\t" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_reference_https_public_approved_037", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "https" + } + }, + { + "id": "blossom_reference_https_private_address_approved_038", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "https://10.0.0.4/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "https" + } + }, + { + "id": "blossom_reference_http_localhost_approved_039", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://LOCALHOST:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_localhost_subdomain_approved_040", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://media.localhost/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_nested_localhost_subdomain_approved_041", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://blossom.dev.localhost/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_ipv4_loopback_approved_042", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://127.0.0.1:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_ipv4_loopback_range_approved_043", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://127.255.255.254/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_ipv6_loopback_approved_044", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://[::1]:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_expanded_ipv6_loopback_approved_045", + "kind": "blossom.reference_policy.valid", + "input": { + "url": "http://[0:0:0:0:0:0:0:1]/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "approved": true, + "transport": "loopback_http" + } + }, + { + "id": "blossom_reference_http_public_rejected_046", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_false_localhost_suffix_rejected_047", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://localhost.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_absolute_localhost_name_rejected_048", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://localhost./ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_before_ipv4_loopback_range_rejected_049", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://126.255.255.255/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_after_ipv4_loopback_range_rejected_050", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://128.0.0.0/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_private_ipv4_rejected_051", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://10.0.0.4/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_unspecified_ipv4_rejected_052", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://0.0.0.0/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_ipv6_link_local_rejected_053", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://[fe80::1]/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_reference_http_ipv4_mapped_ipv6_rejected_054", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://[::ffff:127.0.0.1]/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_blob_url_short_ipv4_spelling_rejected_055", + "kind": "blossom.blob_url.parse.invalid", + "input": { + "url": "http://127.1/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "invalid_blob_url" + } + }, + { + "id": "blossom_reference_http_private_ipv4_second_range_rejected_056", + "kind": "blossom.reference_policy.invalid", + "input": { + "url": "http://192.168.1.4/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png" + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_media_type_image_png_057", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "image/png" + }, + "expected": { + "media_type": "image/png" + } + }, + { + "id": "blossom_media_type_case_canonicalized_058", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "IMAGE/PNG" + }, + "expected": { + "media_type": "image/png" + } + }, + { + "id": "blossom_media_type_vendor_token_059", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "application/vnd.radroots.fixture+json" + }, + "expected": { + "media_type": "application/vnd.radroots.fixture+json" + } + }, + { + "id": "blossom_media_type_empty_060", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_parameters_sorted_and_canonicalized_061", + "kind": "blossom.media_type.parse.valid", + "input": { + "media_type": "TEXT/PLAIN; PROFILE=web; CHARSET=UTF-8" + }, + "expected": { + "media_type": "text/plain; charset=UTF-8; profile=web" + } + }, + { + "id": "blossom_media_type_duplicate_parameter_rejected_061b", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/png; profile=a; PROFILE=b" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_missing_separator_062", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "imagepng" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_empty_subtype_063", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_wildcard_forbidden_064", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/*" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_parser_truncated_whitespace_forbidden_065", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/png " + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_media_type_non_ascii_forbidden_066", + "kind": "blossom.media_type.parse.invalid", + "input": { + "media_type": "image/p\u00f1g" + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_descriptor_https_valid_067", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + } + }, + { + "id": "blossom_descriptor_empty_blob_extra_fields_valid_068", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.bin", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "type": "application/octet-stream", + "uploaded": 0, + "blurhash": "fixture-only-extra-field", + "custom": { + "version": 1 + } + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.bin", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "type": "application/octet-stream", + "uploaded": 0 + } + } + }, + { + "id": "blossom_descriptor_extension_does_not_define_mime_069", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.jpg", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000001 + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.jpg", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000001 + } + } + }, + { + "id": "blossom_descriptor_public_http_structurally_valid_070", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000002 + } + }, + "expected": { + "descriptor": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000002 + } + } + }, + { + "id": "blossom_descriptor_media_type_canonicalized_071", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "http://127.0.0.1:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.PNG", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "IMAGE/PNG", + "uploaded": 0 + } + }, + "expected": { + "descriptor": { + "url": "http://127.0.0.1:21100/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.PNG", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 0 + } + } + }, + { + "id": "blossom_descriptor_missing_url_072", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "url" + } + }, + { + "id": "blossom_descriptor_missing_sha256_073", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "sha256" + } + }, + { + "id": "blossom_descriptor_missing_size_074", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "size" + } + }, + { + "id": "blossom_descriptor_missing_type_075", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "uploaded": 1700000000 + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "type" + } + }, + { + "id": "blossom_descriptor_missing_uploaded_076", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain" + } + }, + "expected": { + "error": "missing_descriptor_field", + "field": "uploaded" + } + }, + { + "id": "blossom_descriptor_url_extension_required_077", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "descriptor_extension_required" + } + }, + { + "id": "blossom_descriptor_url_hash_mismatch_078", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "descriptor_hash_mismatch" + } + }, + { + "id": "blossom_descriptor_negative_size_079", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": -1, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_descriptor_size" + } + }, + { + "id": "blossom_descriptor_fractional_size_080", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3.5, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_descriptor_size" + } + }, + { + "id": "blossom_descriptor_negative_uploaded_081", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": -1 + } + }, + "expected": { + "error": "invalid_descriptor_uploaded" + } + }, + { + "id": "blossom_descriptor_parameterized_media_type_valid_082", + "kind": "blossom.descriptor.parse.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "TEXT/PLAIN; PROFILE=mobile; CHARSET=UTF-8", + "uploaded": 1700000000 + } + }, + "expected": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain; charset=UTF-8; profile=mobile", + "uploaded": 1700000000 + } + } + }, + { + "id": "blossom_descriptor_invalid_media_type_082b", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_media_type" + } + }, + { + "id": "blossom_descriptor_uppercase_sha256_083", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "invalid_sha256" + } + }, + { + "id": "blossom_descriptor_url_query_forbidden_084", + "kind": "blossom.descriptor.parse.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt?download=1", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "blob_url_query_forbidden" + } + }, + { + "id": "blossom_descriptor_approved_https_reference_084b", + "kind": "blossom.descriptor.approve_reference.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "approved_url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt" + } + }, + { + "id": "blossom_descriptor_public_http_reference_rejected_084c", + "kind": "blossom.descriptor.approve_reference.invalid", + "input": { + "descriptor": { + "url": "http://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + } + }, + "expected": { + "error": "insecure_blob_url" + } + }, + { + "id": "blossom_descriptor_verify_abc_valid_085", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "text/plain", + "bytes_hex": "616263" + }, + "expected": { + "verified": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "media_type": "text/plain" + } + } + }, + { + "id": "blossom_descriptor_verify_empty_valid_086", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.bin", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "type": "application/octet-stream", + "uploaded": 1700000000 + }, + "approved_media_type": "application/octet-stream", + "bytes_hex": "" + }, + "expected": { + "verified": { + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0, + "media_type": "application/octet-stream" + } + } + }, + { + "id": "blossom_descriptor_verify_no_sniff_or_extension_inference_087", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.jpg", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000000 + }, + "approved_media_type": "image/png", + "bytes_hex": "616263" + }, + "expected": { + "verified": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "media_type": "image/png" + } + } + }, + { + "id": "blossom_descriptor_verify_approved_media_type_canonicalized_088", + "kind": "blossom.descriptor.verify_bytes.valid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.png", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "image/png", + "uploaded": 1700000000 + }, + "approved_media_type": "IMAGE/PNG", + "bytes_hex": "616263" + }, + "expected": { + "verified": { + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "media_type": "image/png" + } + } + }, + { + "id": "blossom_descriptor_verify_hash_mismatch_089", + "kind": "blossom.descriptor.verify_bytes.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 7, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "text/plain", + "bytes_hex": "6368616e676564" + }, + "expected": { + "error": "blob_hash_mismatch" + } + }, + { + "id": "blossom_descriptor_verify_size_mismatch_090", + "kind": "blossom.descriptor.verify_bytes.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 4, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "text/plain", + "bytes_hex": "616263" + }, + "expected": { + "error": "blob_size_mismatch" + } + }, + { + "id": "blossom_descriptor_verify_media_type_mismatch_091", + "kind": "blossom.descriptor.verify_bytes.invalid", + "input": { + "descriptor": { + "url": "https://media.example/ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad.txt", + "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "size": 3, + "type": "text/plain", + "uploaded": 1700000000 + }, + "approved_media_type": "application/octet-stream", + "bytes_hex": "616263" + }, + "expected": { + "error": "blob_media_type_mismatch" + } + } + ] +}