From 88b21835425e3dc3e1cc58872a7ac91ec22543c6 Mon Sep 17 00:00:00 2001 From: "David E. Wheeler" Date: Mon, 23 Sep 2024 22:55:03 +0100 Subject: [PATCH] Add release module The new `Release` struct encapsulates `Distribution` and adds a `release` field that contains JWS-signed release metadata. It currently only supports v2 metadata, but offers the same traits, constructors, and accessors as `Distribution`, plus the `release()` accessor and associated objects. Add tests for all of these bits, using both the v2 corpus with a release patch applied and custom JSON objects to test each individual struct and its accessors. Writing the tests for the `TryFrom` when what was on hand was a `Path` led to a bit of research and the conclusion that one does not convert a file path into a struct, but loads it into a struct. So create the `load` function, instead, and have it accept a `AsRef` argument, which covers `Path`s, `PathBuf`s, and strings. Back-patch this change to `Distribution`, as well, and take advantage of `.try_into()` where possible. --- Cargo.lock | 439 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 + src/lib.rs | 1 + src/meta/mod.rs | 23 +- src/meta/tests.rs | 6 +- src/meta/v1/tests.rs | 1 + src/release/mod.rs | 463 +++++++++++++++++++++++++++++++++++++++ src/release/tests.rs | 505 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1421 insertions(+), 21 deletions(-) create mode 100644 src/release/mod.rs create mode 100644 src/release/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 846c26f..f761414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,6 +24,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "appendlist" version = "1.4.0" @@ -40,18 +55,36 @@ dependencies = [ "serde_json", ] +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + [[package]] name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + [[package]] name = "boon" version = "0.6.0" @@ -60,7 +93,7 @@ checksum = "9672cb0edeadf721484e298c0ed4dd70b0eaa3acaed5b4fd0bd73ca32e51d814" dependencies = [ "ahash", "appendlist", - "base64", + "base64 0.21.7", "fluent-uri", "idna", "once_cell", @@ -72,12 +105,42 @@ dependencies = [ "url", ] +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "cc" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" +dependencies = [ + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets", +] + [[package]] name = "const_format" version = "0.2.32" @@ -98,6 +161,57 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "either" version = "1.13.0" @@ -113,15 +227,43 @@ dependencies = [ "serde", ] +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + [[package]] name = "fluent-uri" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -142,6 +284,53 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.5.0" @@ -152,6 +341,28 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", + "serde", +] + [[package]] name = "itertools" version = "0.11.0" @@ -167,6 +378,15 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + [[package]] name = "json-patch" version = "2.0.0" @@ -198,9 +418,21 @@ checksum = "baff4b617f7df3d896f97fe922b64817f6cd9a756bb81d40f8883f2f66dcb401" [[package]] name = "libc" -version = "0.2.155" +version = "0.2.158" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "memchr" @@ -224,6 +456,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -242,14 +489,18 @@ version = "0.2.0" dependencies = [ "assert-json-diff", "boon", + "chrono", "email_address", + "hex", "json-patch", "lexopt", "relative-path", "semver", "serde", "serde_json", + "serde_with", "spdx", + "tempfile", "wax", ] @@ -262,6 +513,12 @@ dependencies = [ "nom", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.86" @@ -318,6 +575,19 @@ dependencies = [ "serde", ] +[[package]] +name = "rustix" +version = "0.38.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.18" @@ -373,6 +643,42 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cecfa94848272156ea67b2b1a53f20fc7bc638c4a46d2f8abde08f05f4b857" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.5.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "smallvec" version = "1.13.2" @@ -388,6 +694,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.68" @@ -399,6 +711,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "thiserror" version = "1.0.63" @@ -419,6 +744,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.1" @@ -494,6 +850,61 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + [[package]] name = "wax" version = "0.6.0" @@ -515,7 +926,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ - "windows-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", ] [[package]] @@ -527,6 +947,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index cfcd0d2..63801be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ exclude = [ ".github", ".vscode", ".gitignore", ".ci", ".pre-*.yaml"] [dependencies] boon = "0.6" +chrono = { version = "0.4.38", features = ["serde"] } email_address = "0.2.9" json-patch = "2.0.0" lexopt = "0.3.0" @@ -23,6 +24,7 @@ relative-path = { version = "1.9", features = ["serde"] } semver = { version = "1.0", features = ["std", "serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1.0" +serde_with = { version = "3.9.0", features = ["hex"] } spdx = "0.10.6" wax = "0.6.0" @@ -32,3 +34,5 @@ serde_json = "1.0" [dev-dependencies] assert-json-diff = "2.0.2" +hex = "0.4.3" +tempfile = "3.12.0" diff --git a/src/lib.rs b/src/lib.rs index 00c74fc..19668f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ files. It supports both the [v1] and [v2] specs. */ pub mod meta; +pub mod release; mod util; // private utilities pub mod valid; diff --git a/src/meta/mod.rs b/src/meta/mod.rs index 287f206..9ef9181 100644 --- a/src/meta/mod.rs +++ b/src/meta/mod.rs @@ -8,7 +8,7 @@ This module provides interfaces to load, validate, and manipulate PGXN [v2]: https://github.com/pgxn/rfcs/pull/3 */ -use std::{borrow::Borrow, collections::HashMap, error::Error, fs::File, path::PathBuf}; +use std::{borrow::Borrow, collections::HashMap, error::Error, fs::File, path::Path}; use crate::util; use relative_path::{RelativePath, RelativePathBuf}; @@ -836,6 +836,14 @@ impl Distribution { } } + /// Loads the release `META.json` data from `file` then converts into a + /// [`Distribution`]. Returns an error on file error or if the content of + /// `file` is not valid PGXN `META.json` data. + pub fn load>(file: P) -> Result> { + let meta: Value = serde_json::from_reader(File::open(file)?)?; + meta.try_into() + } + /// Borrows the Distribution name. pub fn name(&self) -> &str { self.name.as_str() @@ -1073,24 +1081,13 @@ impl TryFrom for Value { } } -impl TryFrom<&PathBuf> for Distribution { - type Error = Box; - /// Reads the `META.json` data from `file` then converts into a - /// [`Distribution`]. Returns an error on file error or if the content of - /// `file` is not valid PGXN `META.json` data. - fn try_from(file: &PathBuf) -> Result { - let meta: Value = serde_json::from_reader(File::open(file)?)?; - Distribution::try_from(meta) - } -} - impl TryFrom<&String> for Distribution { type Error = Box; /// Converts `str` into JSON and then into a [`Distribution`]. Returns an /// error if the content of `str` is not valid PGXN `META.json` data. fn try_from(str: &String) -> Result { let meta: Value = serde_json::from_str(str)?; - Distribution::try_from(meta) + meta.try_into() } } diff --git a/src/meta/tests.rs b/src/meta/tests.rs index e59db94..99d3add 100644 --- a/src/meta/tests.rs +++ b/src/meta/tests.rs @@ -19,8 +19,8 @@ fn test_corpus() -> Result<(), Box> { let path = path?.into_path(); let contents: Value = serde_json::from_reader(File::open(&path)?)?; - // Test try_from path. - if let Err(e) = Distribution::try_from(&path) { + // Test load path. + if let Err(e) = Distribution::load(&path) { panic!("{v_dir}/{:?} failed: {e}", path.file_name().unwrap()); } @@ -1347,7 +1347,7 @@ fn test_distribution() -> Result<(), Box> { let name = path.as_path().to_str().unwrap(); let contents: Value = serde_json::from_reader(File::open(&path)?)?; - match Distribution::try_from(&path) { + match Distribution::load(&path) { Err(e) => panic!("{name} failed: {e}"), Ok(dist) => { // Required fields. diff --git a/src/meta/v1/tests.rs b/src/meta/v1/tests.rs index b63e922..64ae603 100644 --- a/src/meta/v1/tests.rs +++ b/src/meta/v1/tests.rs @@ -1,4 +1,5 @@ use super::*; +use std::path::PathBuf; #[test] fn test_v1_v2_common() { diff --git a/src/release/mod.rs b/src/release/mod.rs new file mode 100644 index 0000000..7b79a5c --- /dev/null +++ b/src/release/mod.rs @@ -0,0 +1,463 @@ +/*! +PGXN release `META.json` validation and management. + +This module provides interfaces to load, validate, and manipulate PGXN release +`META.json` files. PGXN adds release metadata to distribution-provided +[v1] and [v2] `META.json` data to identify the user who made a release, the +timestamp, hash digests of the release file. In [v2], it also includes a +download URI and a private key signature. + +Use [`Distribution`] to validate the `META.json` file included in a +distribution. + +It supports both the [v1] and [v2] specs. + + [v1]: https://rfcs.pgxn.org/0001-meta-spec-v1.html + [v2]: https://github.com/pgxn/rfcs/pull/3 + +*/ + +use crate::meta::*; +use crate::util; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{borrow::Borrow, collections::HashMap, error::Error, fs::File, path::Path}; + +/// Digests represents Hash digests for a file that can be used to verify its +/// integrity. +#[serde_with::serde_as] +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct Digests { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde_as(as = "Option")] + sha1: Option<[u8; 20]>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde_as(as = "Option")] + sha256: Option<[u8; 32]>, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde_as(as = "Option")] + sha512: Option<[u8; 64]>, +} + +impl Digests { + /// Borrows the SHA-1 hash. + pub fn sha1(&self) -> Option<&[u8; 20]> { + self.sha1.as_ref() + } + + /// Borrows the SHA-256 hash. + pub fn sha256(&self) -> Option<&[u8; 32]> { + self.sha256.as_ref() + } + + /// Borrows the SHA-256 hash. + pub fn sha512(&self) -> Option<&[u8; 64]> { + self.sha512.as_ref() + } +} + +/// ReleasePayload represents release metadata populated by PGXN. +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct ReleasePayload { + user: String, + date: DateTime, + uri: String, + digests: Digests, +} + +impl ReleasePayload { + /// Borrows the release user name. + pub fn user(&self) -> &str { + self.user.as_str() + } + + /// Borrows the release date. + pub fn date(&self) -> &DateTime { + self.date.borrow() + } + + /// Borrows the release URI. + pub fn uri(&self) -> &str { + self.uri.as_str() + } + + /// Borrows the release digests. + pub fn digests(&self) -> &Digests { + self.digests.borrow() + } +} + +/// ReleaseJws represents JSON Web Signature release metadata populated by +/// PGXN. +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct ReleaseJws { + headers: Vec, + signatures: Vec, + payload: ReleasePayload, +} + +impl ReleaseJws { + /// Borrows the signature headers. + pub fn headers(&self) -> &[String] { + self.headers.as_slice() + } + + /// Borrows the signatures. + pub fn signatures(&self) -> &[String] { + self.signatures.as_slice() + } + + /// Borrows the release payload. + pub fn payload(&self) -> &ReleasePayload { + self.payload.borrow() + } +} + +/** + +Represents metadata for a PGXN release, which is the same as [`Distribution`] +plus [`ReleaseJws`] that contains signed metadata about the release to PGXN. + +*/ +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct Release { + #[serde(flatten)] + dist: Distribution, + release: ReleaseJws, +} + +impl Release { + // It would be nice to use [delegation] at some point instead of + // copy/pasting all the Distribution methods, but this will do for now. + // [delegation]: https://github.com/rust-lang/rfcs/pull/3530 + + /// Deserializes `meta`, which contains PGXN disribution release metadata, + /// into a [`Release`]. + fn from_version(version: u8, meta: Value) -> Result> { + match version { + // 1 => v1::from_value(meta), + 2 => match serde_json::from_value(meta) { + Ok(m) => Ok(m), + Err(e) => Err(Box::from(e)), + }, + _ => Err(Box::from(format!("Unknown meta version {version}"))), + } + + // XXX: Add signature validation. + } + + /// Loads the release `META.json` data from `file` then converts into a + /// [`Release`]. Returns an error on file error or if the content of + /// `file` is not valid PGXN `META.json` data. + pub fn load>(file: P) -> Result> { + let meta: Value = serde_json::from_reader(File::open(file)?)?; + meta.try_into() + } + + /// Borrows the Distribution name. + pub fn name(&self) -> &str { + self.dist.name() + } + + /// Borrows the Distribution version. + pub fn version(&self) -> &semver::Version { + self.dist.version() + } + + /// Borrows the Distribution abstract. + pub fn abs_tract(&self) -> &str { + self.dist.abs_tract() + } + + /// Borrows the Distribution description. + pub fn description(&self) -> Option<&str> { + self.dist.description() + } + + /// Borrows the Distribution producer. + pub fn producer(&self) -> Option<&str> { + self.dist.producer() + } + + /// Borrows the Distribution license string. + pub fn license(&self) -> &str { + self.dist.license() + } + + /// Borrows the Distribution meta spec object. + pub fn spec(&self) -> &Spec { + self.dist.spec() + } + + /// Borrows the Distribution maintainers collection. + pub fn maintainers(&self) -> &[Maintainer] { + self.dist.maintainers() + } + + /// Borrows the Dependencies classifications object. + pub fn classifications(&self) -> Option<&Classifications> { + self.dist.classifications() + } + + /// Borrows the Distribution contents object. + pub fn contents(&self) -> &Contents { + self.dist.contents() + } + + /// Borrows the Distribution ignore list. + pub fn ignore(&self) -> Option<&[String]> { + self.dist.ignore() + } + + /// Borrows the Distribution meta dependencies object. + pub fn dependencies(&self) -> Option<&Dependencies> { + self.dist.dependencies() + } + + /// Borrows the Distribution meta resources object. + pub fn resources(&self) -> Option<&Resources> { + self.dist.resources() + } + + /// Borrows the Distribution artifacts list. + pub fn artifacts(&self) -> Option<&[Artifact]> { + self.dist.artifacts() + } + + /// Borrows the Distribution release metadata. + pub fn release(&self) -> &ReleaseJws { + self.release.borrow() + } + + /// Borrows the custom_props object, which holds any `x_` or `X_` + /// properties + pub fn custom_props(&self) -> &HashMap { + self.dist.custom_props() + } +} + +impl TryFrom for Release { + type Error = Box; + /// Converts the PGXN release `META.json` data from `meta` into a + /// [`Release`]. Returns an error if `meta` is invalid. + /// + /// # Example + /// + /// ``` rust + /// # use std::error::Error; + /// use serde_json::json; + /// use pgxn_meta::release::*; + /// + /// let meta_json = json!({ + /// "name": "pair", + /// "abstract": "A key/value pair data type", + /// "version": "0.1.8", + /// "maintainers": [ + /// { "name": "Barrack Obama", "email": "pogus@example.com" } + /// ], + /// "license": "PostgreSQL", + /// "contents": { + /// "extensions": { + /// "pair": { + /// "sql": "sql/pair.sql", + /// "control": "pair.control" + /// } + /// } + /// }, + /// "meta-spec": { "version": "2.0.0" }, + /// "release": { + /// "headers": ["eyJhbGciOiJFUzI1NiJ9"], + /// "signatures": [ + /// "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q" + /// ], + /// "payload": { + /// "user": "xxx", + /// "date": "2024-07-20T20:34:34Z", + /// "uri": "/dist/semver/0.40.0/semver-0.40.0.zip", + /// "digests": { + /// "sha1": "fe8c013f991b5f537c39fb0c0b04bc955457675a" + /// } + /// } + /// } + /// }); + /// + /// + /// let meta = Release::try_from(meta_json); + /// assert!(meta.is_ok(), "{:?}", meta); + /// ``` + fn try_from(meta: Value) -> Result { + // Make sure it's valid. + let mut validator = crate::valid::Validator::new(); + let version = match validator.validate_release(&meta) { + Err(e) => return Err(Box::from(e.to_string())), + Ok(v) => v, + }; + Release::from_version(version, meta) + } +} + +impl TryFrom<&[&Value]> for Release { + type Error = Box; + /// Merge multiple PGXN release `META.json` data from `meta` into a + /// [`Release`]. Returns an error if `meta` is invalid. + /// + /// The first value in `meta` should be the primary metadata, generally + /// included in a distribution. Subsequent values will be merged into that + /// first value via the [RFC 7396] merge pattern. + /// + /// # Example + /// + /// ``` rust + /// # use std::error::Error; + /// use serde_json::json; + /// use pgxn_meta::release::*; + /// + /// let meta_json = json!({ + /// "name": "pair", + /// "abstract": "A key/value pair data type", + /// "version": "0.1.8", + /// "maintainers": [ + /// { "name": "Barrack Obama", "email": "pogus@example.com" } + /// ], + /// "license": "PostgreSQL", + /// "contents": { + /// "extensions": { + /// "pair": { + /// "sql": "sql/pair.sql", + /// "control": "pair.control" + /// } + /// } + /// }, + /// "meta-spec": { "version": "2.0.0" }, + /// "release": { + /// "headers": ["eyJhbGciOiJFUzI1NiJ9"], + /// "signatures": [ + /// "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q" + /// ], + /// "payload": { + /// "user": "xxx", + /// "date": "2024-07-20T20:34:34Z", + /// "uri": "/dist/semver/0.40.0/semver-0.40.0.zip", + /// "digests": { + /// "sha1": "fe8c013f991b5f537c39fb0c0b04bc955457675a" + /// } + /// } + /// } + /// }); + /// + /// let patch = json!({"license": "MIT"}); + /// let all_meta = [&meta_json, &patch]; + /// + /// let meta = Release::try_from(&all_meta[..]); + /// assert!(meta.is_ok()); + /// assert_eq!("MIT", meta.unwrap().license()); + /// ``` + /// + /// [RFC 7396]: https:///www.rfc-editor.org/rfc/rfc7396.html + fn try_from(meta: &[&Value]) -> Result { + if meta.is_empty() { + return Err(Box::from("meta contains no values")); + } + + // Find the version of the first doc. + let version = + util::get_version(meta[0]).ok_or("No spec version found in first meta value")?; + + // Convert the first doc to v2 if necessary. + let mut v2 = match version { + // 1 => v1::to_v2(meta[0])?, + 2 => meta[0].clone(), + _ => unreachable!(), + }; + + // Merge them. + for patch in meta[1..].iter() { + json_patch::merge(&mut v2, patch) + } + + // Validate the patched doc and return. + let mut validator = crate::valid::Validator::new(); + validator.validate_release(&v2).map_err(|e| e.to_string())?; + Release::from_version(2, v2) + } +} + +impl TryFrom for Value { + type Error = Box; + /// Converts PGXN release `meta` into a [serde_json::Value]. + /// + /// # Example + /// + /// ``` rust + /// # use std::error::Error; + /// use serde_json::{json, Value}; + /// use pgxn_meta::release::*; + /// + /// let meta_json = json!({ + /// "name": "pair", + /// "abstract": "A key/value pair data type", + /// "version": "0.1.8", + /// "maintainers": [ + /// { "name": "Barrack Obama", "email": "pogus@example.com" } + /// ], + /// "license": "PostgreSQL", + /// "contents": { + /// "extensions": { + /// "pair": { + /// "sql": "sql/pair.sql", + /// "control": "pair.control" + /// } + /// } + /// }, + /// "meta-spec": { "version": "2.0.0" }, + /// "release": { + /// "headers": ["eyJhbGciOiJFUzI1NiJ9"], + /// "signatures": [ + /// "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q" + /// ], + /// "payload": { + /// "user": "xxx", + /// "date": "2024-07-20T20:34:34Z", + /// "uri": "/dist/semver/0.40.0/semver-0.40.0.zip", + /// "digests": { + /// "sha1": "fe8c013f991b5f537c39fb0c0b04bc955457675a" + /// } + /// } + /// } + /// }); + /// + /// + /// let meta = Release::try_from(meta_json); + /// assert!(meta.is_ok()); + /// let val: Result> = meta.unwrap().try_into(); + /// assert!(val.is_ok()); + /// ``` + fn try_from(meta: Release) -> Result { + let val = serde_json::to_value(meta)?; + Ok(val) + } +} + +impl TryFrom<&String> for Release { + type Error = Box; + /// Converts `str` into JSON and then into a [`Release`]. Returns an + /// error if the content of `str` is not valid PGXN `META.json` data. + fn try_from(str: &String) -> Result { + let meta: Value = serde_json::from_str(str)?; + meta.try_into() + } +} + +impl TryFrom for String { + type Error = Box; + /// Converts `meta` into a JSON String. + fn try_from(meta: Release) -> Result { + let val = serde_json::to_string(&meta)?; + Ok(val) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/release/tests.rs b/src/release/tests.rs new file mode 100644 index 0000000..ee36416 --- /dev/null +++ b/src/release/tests.rs @@ -0,0 +1,505 @@ +use super::*; +use chrono::prelude::*; +use serde_json::{json, Value}; +use std::{error::Error, fs::File, io::Write, path::PathBuf}; +use tempfile::NamedTempFile; +use wax::Glob; + +fn release_meta() -> Value { + json!({"release": { + "headers": ["eyJhbGciOiJFUzI1NiJ9"], + "signatures": [ + "DtEhU3ljbEg8L38VWAfUAqOyKAM6-Xx-F4GawxaepmXFCgfTjDxw5djxLa8ISlSApmWQxfKTUJqPP3-Kg6NU1Q" + ], + "payload": { + "user": "theory", + "date": "2024-07-20T20:34:34Z", + "uri": "/dist/semver/0.40.0/semver-0.40.0.zip", + "digests": { + "sha1": "fe8c013f991b5f537c39fb0c0b04bc955457675a" + } + } + }}) +} + +fn release_date() -> DateTime { + Utc.with_ymd_and_hms(2024, 7, 20, 20, 34, 34).unwrap() +} + +#[test] +fn test_corpus() -> Result<(), Box> { + for (version, release_patch) in [ + ( + 1, + json!({ + "user": "theory", + "date": "2019-09-23T17:16:45Z", + "sha1": "0389be689af6992b4da520ec510d147bae411e8b", + }), + ), + (2, release_meta()), + ] { + // Skip version 1 for now. + if version == 1 { + continue; + } + + let v_dir = format!("v{version}"); + let dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "corpus", &v_dir] + .iter() + .collect(); + let glob = Glob::new("*.json")?; + + for path in glob.walk(dir) { + // Load and patch metadata. + let path = path?.into_path(); + let bn = path.file_name().unwrap().to_str().unwrap(); + let mut meta: Value = serde_json::from_reader(File::open(&path)?)?; + json_patch::merge(&mut meta, &release_patch); + + // Test try_from value. + match Release::try_from(meta.clone()) { + Err(e) => panic!("{v_dir}/{bn} failed: {e}"), + Ok(release) => { + // Validate that release data was loaded. + if version == 2 { + assert_eq!( + meta.get("license").unwrap(), + release.license(), + "{v_dir}/{bn} license", + ); + assert_eq!( + meta.get("release") + .unwrap() + .get("payload") + .unwrap() + .get("user") + .unwrap(), + release.release().payload().user(), + "{v_dir}/{bn} release user", + ); + + // Make sure round-trip produces the same JSON. + let output: Result> = release.try_into(); + match output { + Err(e) => panic!("{v_dir}/{bn} failed: {e}"), + Ok(val) => { + assert_json_diff::assert_json_eq!(&meta, &val); + } + }; + } + } + } + + // Test try_from string. + let str = meta.to_string(); + match Release::try_from(&str) { + Err(e) => panic!("{v_dir}/{bn} failed: {e}"), + Ok(dist) => { + if version == 2 { + // Make sure value round-trip produces the same JSON. + let output: Result> = dist.try_into(); + match output { + Err(e) => panic!("{v_dir}/{bn} failed: {e}"), + Ok(val) => { + let val: Value = serde_json::from_str(&val)?; + assert_json_diff::assert_json_eq!(&meta, &val); + } + }; + } + } + } + + // Test load path. + let mut file = NamedTempFile::new()?; + write!(file, "{str}")?; + file.flush()?; + let path = file.path(); + if let Err(e) = Release::load(path) { + panic!("{v_dir}/{:?} failed: {e}", path.file_name().unwrap()); + } + } + } + + Ok(()) +} + +#[test] +fn test_bad_corpus() -> Result<(), Box> { + // Load valid distribution metadata. + let file: PathBuf = [env!("CARGO_MANIFEST_DIR"), "corpus", "invalid.json"] + .iter() + .collect(); + let bn = file.file_name().unwrap().to_str().unwrap(); + let mut meta: Value = serde_json::from_reader(File::open(&file)?)?; + + // Patch it with release metadata. + let patch = release_meta(); + json_patch::merge(&mut meta, &patch); + + // Make sure we catch the validation failure. + match Release::try_from(meta.clone()) { + Ok(_) => panic!("Should have failed on {bn} but did not"), + Err(e) => assert!( + e.to_string().contains(" missing properties 'version'"), + "{e}" + ), + } + + // Make sure we fail on invalid version. + match Release::from_version(99, meta.clone()) { + Ok(_) => panic!("Unexpected success with invalid version"), + Err(e) => assert_eq!("Unknown meta version 99", e.to_string(),), + } + + // Should fail when no meta-spec. + meta.as_object_mut().unwrap().remove("meta-spec"); + match Release::try_from(meta.clone()) { + Ok(_) => panic!("Unexpected success with no meta-spec"), + Err(e) => assert_eq!("Cannot determine meta-spec version", e.to_string()), + } + + // Should fail on missing release object. + let obj = meta.as_object_mut().unwrap(); + obj.insert("meta-spec".to_string(), json!({"version": "2.0.0"})); + obj.remove("release"); + match Release::try_from(meta.clone()) { + Ok(_) => panic!("Unexpected success with no release property"), + Err(e) => assert!( + e.to_string().contains(" missing properties 'release'"), + "{e}", + ), + } + + // Make sure we catch a failure parsing into a Release struct. + match Release::from_version(2, json!({"invalid": true})) { + Ok(_) => panic!("Unexpected success with invalid schema"), + Err(e) => assert_eq!("missing field `release`", e.to_string()), + } + + Ok(()) +} + +#[test] +fn test_try_merge_v2() -> Result<(), Box> { + // Load a v2 META file. + let dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "corpus"].iter().collect(); + let widget_file = dir.join("v2").join("minimal.json"); + let contents: Value = serde_json::from_reader(File::open(&widget_file)?)?; + + // expect maps a JSON pointer to an expected value. + for (name, patches, expect) in [ + ( + "license", + vec![json!({"license": "MIT"})], + json!({"/license": "MIT"}), + ), + ( + "tle", + vec![json!({"contents": {"extensions": {"pair": {"tle": true}}}})], + json!({"/contents/extensions/pair": { + "sql": "sql/pair.sql", + "control": "pair.control", + "tle": true, + }}), + ), + ( + "multiple patches", + vec![ + json!({"license": "MIT"}), + json!({"classifications": {"categories": ["Analytics", "Connectors"]}}), + ], + json!({ + "/license": "MIT", + "/classifications/categories": ["Analytics", "Connectors"], + }), + ), + ] { + run_merge_case(name, &contents, patches.as_slice(), &expect)?; + } + + Ok(()) +} + +fn run_merge_case( + name: &str, + orig: &Value, + patches: &[Value], + expect: &Value, +) -> Result<(), Box> { + let release = release_meta(); + let mut meta = vec![orig, &release]; + for p in patches { + meta.push(p); + } + match Release::try_from(meta.as_slice()) { + Err(e) => panic!("patching {name} failed: {e}"), + Ok(dist) => { + // Convert the Release object to JSON. + let output: Result> = dist.try_into(); + match output { + Err(e) => panic!("{name} serialization failed: {e}"), + Ok(val) => { + // Compare expected values at pointers. + for (p, v) in expect.as_object().unwrap() { + assert_eq!(v, val.pointer(p).unwrap()) + } + } + } + } + } + + Ok(()) +} + +#[test] +fn test_try_merge_err() -> Result<(), Box> { + // Load invalid meta. + let dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "corpus"].iter().collect(); + let widget_file = dir.join("invalid.json"); + let invalid: Value = serde_json::from_reader(File::open(&widget_file)?)?; + + let empty = json!({}); + let bad_version = json!({"meta-spec": { "version": null}}); + + for (name, arg, err) in [ + ("no meta", vec![], "meta contains no values"), + ( + "no version", + vec![&empty], + "No spec version found in first meta value", + ), + ( + "bad version", + vec![&bad_version], + "No spec version found in first meta value", + ), + ("invalid", vec![&invalid], "missing properties 'version'"), + ] { + match Release::try_from(arg.as_slice()) { + Ok(_) => panic!("patching {name} unexpectedly succeeded"), + Err(e) => assert!(e.to_string().contains(err), "{name}: {e}"), + } + } + + Ok(()) +} + +#[test] +fn digests() { + for (name, json) in [ + ( + "sha1", + json!({"sha1": "fe8c013f991b5f537c39fb0c0b04bc955457675a"}), + ), + ( + "sha256", + json!({"sha256": "0b68ee2ce5b2c0641c6c429ed2ce17e2ed76ddd58bf9a16e698c5069d60aa34e"}), + ), + ( + "sha512", + json!({"sha512": "22e06f682a7fec79f814f06b5dcea0b06133890775ddc624de744cd5d4e8d5fe29863ba5e77c6d3690b610dbcdf7d79a973561fdfbd8454508998446af8f2c58"}), + ), + ( + "all three", + json!({ + "sha1": "fe8c013f991b5f537c39fb0c0b04bc955457675a", + "sha256": "0b68ee2ce5b2c0641c6c429ed2ce17e2ed76ddd58bf9a16e698c5069d60aa34e", + "sha512": "22e06f682a7fec79f814f06b5dcea0b06133890775ddc624de744cd5d4e8d5fe29863ba5e77c6d3690b610dbcdf7d79a973561fdfbd8454508998446af8f2c58", + }), + ), + ] { + let dig: Digests = serde_json::from_value(json.clone()).unwrap(); + match json.get("sha1") { + None => assert!(dig.sha1().is_none(), "{name} url"), + Some(sha) => assert_eq!( + sha.as_str().unwrap(), + hex::encode(dig.sha1().unwrap()), + "{name} sha1" + ), + } + match json.get("sha256") { + None => assert!(dig.sha256().is_none(), "{name} url"), + Some(sha) => assert_eq!( + sha.as_str().unwrap(), + hex::encode(dig.sha256().unwrap()), + "{name} sha256" + ), + } + match json.get("sha512") { + None => assert!(dig.sha512().is_none(), "{name} url"), + Some(sha) => assert_eq!( + sha.as_str().unwrap(), + hex::encode(dig.sha512().unwrap()), + "{name} sha512" + ), + } + } +} + +#[test] +fn release_payload() { + let release = release_meta(); + let payload = release.get("release").unwrap().get("payload").unwrap(); + let date = release_date(); + let sha1 = payload.get("digests").unwrap().get("sha1").unwrap(); + let load: ReleasePayload = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(payload.get("user").unwrap(), load.user(), "payload name"); + assert_eq!(payload.get("uri").unwrap(), load.uri(), "payload uri"); + assert_eq!(&date, load.date(), "payload date"); + assert_eq!( + sha1.as_str().unwrap(), + hex::encode(load.digests().sha1().unwrap()), + "payload digests", + ) +} + +#[test] +fn release_jws() { + let release = release_meta(); + let json = release.get("release").unwrap(); + let pay: ReleasePayload = serde_json::from_value(json.get("payload").unwrap().clone()).unwrap(); + let jws: ReleaseJws = serde_json::from_value(json.clone()).unwrap(); + assert_eq!( + json.get("headers").unwrap().as_array().unwrap(), + jws.headers(), + "headers" + ); + assert_eq!( + json.get("signatures").unwrap().as_array().unwrap(), + jws.signatures(), + "signatures" + ); + assert_eq!(&pay, jws.payload(), "payload"); +} + +#[test] +fn release() -> Result<(), Box> { + let dir: PathBuf = [env!("CARGO_MANIFEST_DIR"), "corpus", "v2"] + .iter() + .collect(); + let glob = Glob::new("*.json")?; + + for path in glob.walk(dir) { + // Load a v2 META file. + let path = path?.into_path(); + let name = path.as_path().to_str().unwrap(); + let mut meta: Value = serde_json::from_reader(File::open(&path)?)?; + + // Patch it. + let patch = release_meta(); + json_patch::merge(&mut meta, &patch); + + // Load it up. + match Release::try_from(meta.clone()) { + Err(e) => panic!("{name} failed: {e}"), + Ok(rel) => { + // Should have the release data. + let jws: ReleaseJws = + serde_json::from_value(patch.get("release").unwrap().clone())?; + assert_eq!(&jws, rel.release(), "{name} release"); + // Required fields. + assert_eq!( + meta.get("name").unwrap().as_str().unwrap(), + rel.name(), + "{name} name", + ); + assert_eq!( + meta.get("version").unwrap().as_str().unwrap(), + rel.version().to_string(), + "{name} version", + ); + assert_eq!( + meta.get("abstract").unwrap().as_str().unwrap(), + rel.abs_tract().to_string(), + "{name} abstract", + ); + assert_eq!( + meta.get("license").unwrap().as_str().unwrap(), + rel.license(), + "{name} license", + ); + + let val: Spec = + serde_json::from_value(meta.get("meta-spec").unwrap().clone()).unwrap(); + assert_eq!(&val, rel.spec(), "{name} spec"); + + let val: Vec = + serde_json::from_value(meta.get("maintainers").unwrap().clone()).unwrap(); + assert_eq!(&val, rel.maintainers(), "{name} maintainers"); + + let val: Contents = + serde_json::from_value(meta.get("contents").unwrap().clone()).unwrap(); + assert_eq!(&val, rel.contents(), "{name} contents"); + + // Optional fields. + match meta.get("description") { + None => assert!(rel.description().is_none(), "{name} description"), + Some(description) => assert_eq!( + description.as_str().unwrap(), + rel.description().unwrap(), + "{name} description" + ), + } + match meta.get("producer") { + None => assert!(rel.producer().is_none(), "{name} producer"), + Some(producer) => assert_eq!( + producer.as_str().unwrap(), + rel.producer().unwrap(), + "{name} producer" + ), + } + match meta.get("classifications") { + None => assert!(rel.classifications().is_none(), "{name} classifications"), + Some(val) => { + let p: Classifications = serde_json::from_value(val.clone()).unwrap(); + assert_eq!(&p, rel.classifications().unwrap(), "{name} classifications"); + } + } + match meta.get("ignore") { + None => assert!(rel.ignore().is_none(), "{name} ignore"), + Some(val) => { + let p: Vec = serde_json::from_value(val.clone()).unwrap(); + assert_eq!(&p, rel.ignore().unwrap(), "{name} ignore"); + } + } + match meta.get("dependencies") { + None => assert!(rel.dependencies().is_none(), "{name} dependencies"), + Some(val) => { + let p: Dependencies = serde_json::from_value(val.clone()).unwrap(); + assert_eq!(&p, rel.dependencies().unwrap(), "{name} dependencies"); + } + } + match meta.get("resources") { + None => assert!(rel.resources().is_none(), "{name} resources"), + Some(val) => { + let p: Resources = serde_json::from_value(val.clone()).unwrap(); + assert_eq!(&p, rel.resources().unwrap(), "{name} resources"); + } + } + match meta.get("artifacts") { + None => assert!(rel.artifacts().is_none(), "{name} artifacts"), + Some(val) => { + let p: Vec = serde_json::from_value(val.clone()).unwrap(); + assert_eq!(&p, rel.artifacts().unwrap(), "{name} artifacts"); + } + } + assert_eq!(&exes_from(&meta), rel.custom_props(), "{name} custom_props"); + } + } + } + + Ok(()) +} + +/// Extracts the subset of val (which must be an instance of Value::Object) +/// where the property names start with `x_` or `X_`. Used for testing +/// custom_props. +fn exes_from(val: &Value) -> HashMap { + val.as_object() + .unwrap() + .into_iter() + .filter(|(key, _)| key.starts_with("x_") || key.starts_with("X_")) + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect() +}