diff --git a/Cargo.lock b/Cargo.lock index 880b4f8..fa157d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,6 +513,38 @@ dependencies = [ "bytes", ] +[[package]] +name = "camino" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "cc" version = "1.1.6" @@ -727,6 +759,12 @@ dependencies = [ "syn 2.0.72", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.10.7" @@ -754,6 +792,40 @@ dependencies = [ "serde", ] +[[package]] +name = "embed-licensing" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f380b9d6229ab32bddd61aa8117da94bfbf6552e085cb0ae4eb81bb5844a0e5d" +dependencies = [ + "embed-licensing-core", + "embed-licensing-macros", + "spdx", +] + +[[package]] +name = "embed-licensing-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ce8d1f31d4e1f4e2c76702247f971a57bcc44a2c52cb4912d00258ae0e19b6" +dependencies = [ + "cargo_metadata", + "proc-macro2", + "quote", + "spdx", + "thiserror", +] + +[[package]] +name = "embed-licensing-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9275a4ab52ca953913ad4d7bc3458856ac01e201310e0f9652d1b4ccb9267dff" +dependencies = [ + "embed-licensing-core", + "quote", +] + [[package]] name = "encoding_rs" version = "0.8.34" @@ -1234,6 +1306,7 @@ dependencies = [ "base64 0.22.1", "clap", "datamatrix", + "embed-licensing", "enum-iterator", "env_logger 0.11.5", "futures-util", @@ -1242,6 +1315,7 @@ dependencies = [ "maud", "mime", "mime_guess", + "pretty_assertions", "printpdf", "quickcheck", "quickcheck_macros", @@ -1249,6 +1323,7 @@ dependencies = [ "serde", "serde_urlencoded", "serde_variant", + "spdx", "sqlx", "thiserror", "time", @@ -1650,6 +1725,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "printpdf" version = "0.7.0" @@ -1918,6 +2003,9 @@ name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -2037,6 +2125,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spdx" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47317bbaf63785b53861e1ae2d11b80d6b624211d42cb20efcd210ee6f8a14bc" +dependencies = [ + "smallvec", +] + [[package]] name = "spin" version = "0.9.8" @@ -2819,6 +2916,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "zerocopy" version = "0.7.35" diff --git a/Cargo.toml b/Cargo.toml index d6559b9..9192844 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ version = "0.0.0" authors = ["Simon Bruder "] edition = "2021" license = "AGPL-3.0-or-later" +repository = "https://git.sbruder.de/simon/li7y" [dependencies] actix-identity = "0.7.1" @@ -21,6 +22,7 @@ enum-iterator = "2.1.0" env_logger = "0.11.3" futures-util = "0.3.30" itertools = "0.13.0" +embed-licensing = "0.1.0" log = "0.4.21" maud = { version = "0.26.0", features = ["actix-web"] } mime = "0.3.17" @@ -30,6 +32,7 @@ rust-embed = { version = "8.5.0", features = ["actix"] } serde = { version = "1.0.203", features = ["serde_derive"] } serde_urlencoded = "0.7.1" serde_variant = "0.1.3" +spdx = { version = "0.10.6", features = ["text"] } sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "uuid", "time"] } thiserror = "1.0.61" time = { version = "0.3.36", features = ["parsing", "serde"] } @@ -37,6 +40,7 @@ uuid = { version = "1.9.0", features = ["serde", "v4"] } [dev-dependencies] actix-http = "3.8.0" +pretty_assertions = "1.4.0" quickcheck = "1.0.3" quickcheck_macros = "1.0.0" diff --git a/src/frontend/licensing.rs b/src/frontend/licensing.rs new file mode 100644 index 0000000..05d609e --- /dev/null +++ b/src/frontend/licensing.rs @@ -0,0 +1,461 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::{BTreeSet, VecDeque}; + +use actix_identity::Identity; +use actix_web::{get, web, Responder}; +use embed_licensing::CrateLicense; +use maud::{html, Markup, Render}; +use spdx::expression::ExprNode; +use spdx::LicenseReq; + +use super::templates; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(licensing); +} + +#[derive(Clone, Debug, PartialOrd, PartialEq, Eq, Ord)] +enum SpdxExpression { + Req(LicenseReq), + And(BTreeSet), + Or(BTreeSet), +} + +fn render_separated_list(list: impl IntoIterator, separator: &str) -> Markup { + let mut iter = list.into_iter(); + html! { + "(" + (iter.next().unwrap()) + @for item in iter { + small .font-monospace { " " (separator) " " } + (item) + } + ")" + } +} + +// TODO: remove the outermost parentheses +impl Render for SpdxExpression { + fn render(&self) -> Markup { + match self { + Self::Req(req) => html! { + @let license = req.license.id().expect("only SPDX license identifiers supported"); + a .font-monospace href={ "#license-" (license.name) } { (license.name) } + @if let Some(exception) = req.exception { + small .font-monospace { " WITH " } + a .font-monospace href={ "#exception-" (exception.name) } { (exception.name) } + } + }, + Self::And(items) => render_separated_list(items, "AND"), + Self::Or(items) => render_separated_list(items, "OR"), + } + } +} + +impl From for SpdxExpression { + fn from(value: spdx::Expression) -> Self { + let mut stack = VecDeque::new(); + let mut expr = None; + + for node in value.iter() { + match node { + ExprNode::Op(op) => { + let last = expr.unwrap_or_else(|| stack.pop_back().unwrap()); + expr = Some(match op { + spdx::expression::Operator::Or => { + SpdxExpression::Or(BTreeSet::from([last, stack.pop_back().unwrap()])) + } + spdx::expression::Operator::And => { + SpdxExpression::And(BTreeSet::from([last, stack.pop_back().unwrap()])) + } + }); + } + ExprNode::Req(req) => { + stack.push_back(SpdxExpression::Req(req.req.clone())); + } + } + } + + // special case for single req + if expr.is_none() && stack.len() == 1 { + return stack.pop_back().unwrap(); + } + + expr.expect("empty expression not possible").simplify() + } +} + +macro_rules! spdx_expression_simplify_impl { + ( $variant:ident, $items:expr ) => {{ + let mut changed = false; + for item in $items.clone().into_iter() { + // if is split to avoid referencing a moved value + if let Self::$variant(_) = item { + $items.remove(&item); + changed = true; + } + if let Self::$variant(mut inner_items) = item { + $items.append(&mut inner_items); + } + } + if changed { + Self::$variant($items).simplify() + } else { + Self::$variant($items.into_iter().map(|it| it.simplify()).collect()) + } + }}; +} + +impl SpdxExpression { + fn simplify(self) -> Self { + match self { + Self::Req(_) => self, + Self::And(mut items) => spdx_expression_simplify_impl!(And, items), + Self::Or(mut items) => spdx_expression_simplify_impl!(Or, items), + } + } +} + +trait Package { + fn name(&self) -> &str; + fn version(&self) -> Option<&str>; + fn authors(&self) -> &[String]; + fn website(&self) -> &str; + fn license(&self) -> &CrateLicense; +} + +impl Render for dyn Package { + fn render(&self) -> Markup { + html! { + div .col-3 { + div .card { + div .card-body { + h4 .card-title { + a href=(self.website()) { (self.name()) } + @if let Some(version) = self.version() { + " " (version) + } + } + } + ul .list-group .list-group-flush { + @if self.authors().is_empty() { + li .list-group-item { em { "no authors specified in Cargo manifest" } } + } + @for author in self.authors() { + li .list-group-item { (author) } + } + } + div .card-body.align-content-end { + @match self.license() { + CrateLicense::SpdxExpression(expr) => { + (SpdxExpression::from(*expr.clone())) + }, + CrateLicense::Other(content) => { + @let modal_id = format!("license-modal-{}-{}", self.name(), self.version().unwrap_or("")); + button + .btn.btn-primary + type="button" + data-bs-toggle="modal" + data-bs-target={ "#" (modal_id) } + { "custom license" } + div .modal.modal-lg #(modal_id) { + div .modal-dialog { + div .modal-content { + div.modal-header { + h1 .modal-title.fs-5 { + "custom license of " + (self.name()) + @if let Some(version) = self.version() { + " " (version) + } + } + button .btn-close type="button" data-bs-dismiss="modal"; + } + div .modal-body { + pre style="text-wrap: wrap" { (content) } + } + } + } + } + }, + } + } + } + } + } + } +} + +impl Package for embed_licensing::Crate { + fn name(&self) -> &str { + &self.name + } + + fn version(&self) -> Option<&str> { + Some(&self.version) + } + + fn authors(&self) -> &[String] { + &self.authors + } + + fn website(&self) -> &str { + &self.website + } + + fn license(&self) -> &CrateLicense { + &self.license + } +} + +struct OtherPackage { + name: String, + website: String, + authors: Vec, + license: CrateLicense, +} + +impl Package for OtherPackage { + fn name(&self) -> &str { + &self.name + } + + fn version(&self) -> Option<&str> { + None + } + + fn authors(&self) -> &[String] { + &self.authors + } + + fn website(&self) -> &str { + &self.website + } + + fn license(&self) -> &CrateLicense { + &self.license + } +} + +#[get("/licensing")] +async fn licensing(user: Option) -> impl Responder { + let cargo_licensing = embed_licensing::collect!(); + + let other_packages = [OtherPackage { + name: "Bootstrap".to_string(), + website: "https://getbootstrap.com/".to_string(), + authors: vec!["The Bootstrap Authors".to_string()], + license: CrateLicense::SpdxExpression(Box::new(spdx::Expression::parse("MIT").unwrap())), + }]; + + let mut licenses = cargo_licensing.licenses; + let mut exceptions = cargo_licensing.exceptions; + + for other_package in &other_packages { + if let CrateLicense::SpdxExpression(expr) = &other_package.license { + for node in expr.iter() { + if let spdx::expression::ExprNode::Req(spdx::expression::ExpressionReq { + req, + .. + }) = node + { + let license = req + .license + .id() + .expect("only SPDX license identifiers supported"); + + if !licenses.contains(&license) { + licenses.push(license) + } + + if let Some(exception) = req.exception { + if !exceptions.contains(&exception) { + exceptions.push(exception) + } + } + } + } + } + } + + licenses.sort_unstable(); + exceptions.sort_unstable(); + + templates::base( + templates::TemplateConfig { + path: "/licensing", + title: Some("Licensing"), + page_title: Some(Box::new("Licensing")), + user, + ..Default::default() + }, + html! { + h3 .mt-4 { "Rust Packages" } + + div .row.g-2 { + @for package in cargo_licensing.packages { + (&package as &dyn Package) + } + } + + h3 .mt-4 { "Other Packages" } + + div .row.g-2 { + @for package in other_packages { + (&package as &dyn Package) + } + } + + h3 .mt-4 { "Licenses" } + + @for license in licenses { + h4 #{ "license-" (license.name) } .mt-3 { (license.full_name) " (" span .font-monospace { (license.name) } ")" } + + pre style="text-wrap: wrap" { (license.text()) } + } + + h3 .mt-4 { "Exceptions" } + + @for exception in exceptions { + h4 #{ "exception-" (exception.name) } .mt-3.font-monospace { (exception.name) } + + pre style="text-wrap: wrap" { (exception.text()) } + } + }, + ) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use pretty_assertions::assert_eq; + use spdx::{exception_id, license_id, LicenseItem, LicenseReq}; + + use super::SpdxExpression; + + fn req(id: &str, or_later: bool, exception: Option<&str>) -> SpdxExpression { + SpdxExpression::Req(LicenseReq { + license: LicenseItem::Spdx { + id: license_id(id).unwrap(), + or_later, + }, + exception: exception.map(exception_id).map(Option::unwrap), + }) + } + + #[test] + fn test_spdx_expression_from_simple() { + assert_eq!( + SpdxExpression::from(spdx::Expression::parse("MIT").unwrap()), + req("MIT", false, None) + ); + } + + #[test] + fn test_spdx_expression_from_complex() { + assert_eq!( + SpdxExpression::from( + spdx::Expression::parse("Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT OR (CC0-1.0 AND Unlicense)") + .unwrap() + ), + SpdxExpression::Or(BTreeSet::from([ + req("Apache-2.0", false, Some("LLVM-exception")), + req("Apache-2.0", false, None), + req("MIT", false, None), + SpdxExpression::And(BTreeSet::from([ + req("CC0-1.0", false, None), + req("Unlicense", false, None), + ])) + ])) + ); + } + + #[test] + fn test_spdx_expression_simplify_or() { + assert_eq!( + SpdxExpression::Or(BTreeSet::from([ + SpdxExpression::Or(BTreeSet::from([])), + SpdxExpression::Or(BTreeSet::from([ + req("Apache-2.0", false, Some("LLVM-exception")), + req("MIT", false, None), + SpdxExpression::Or(BTreeSet::from([ + req("MIT", false, None), + req("Unlicense", false, None), + ])), + ])), + req("LGPL-2.1", true, None), + ])) + .simplify(), + SpdxExpression::Or(BTreeSet::from([ + req("Apache-2.0", false, Some("LLVM-exception")), + req("LGPL-2.1", true, None), + req("MIT", false, None), + req("Unlicense", false, None), + ])) + ); + } + + #[test] + fn test_spdx_expression_simplify_and() { + assert_eq!( + SpdxExpression::And(BTreeSet::from([ + SpdxExpression::And(BTreeSet::from([])), + SpdxExpression::And(BTreeSet::from([ + req("MIT", false, None), + req("Apache-2.0", false, Some("LLVM-exception")), + SpdxExpression::And(BTreeSet::from([ + req("MIT", false, None), + req("Unlicense", false, None), + ])), + ])), + req("LGPL-2.1", true, None), + ])) + .simplify(), + SpdxExpression::And(BTreeSet::from([ + req("Apache-2.0", false, Some("LLVM-exception")), + req("LGPL-2.1", true, None), + req("MIT", false, None), + req("Unlicense", false, None), + ])) + ); + } + + #[test] + fn test_spdx_expression_simplify_mixed() { + assert_eq!( + SpdxExpression::Or(BTreeSet::from([ + SpdxExpression::And(BTreeSet::from([ + req("MIT", false, None), + req("Apache-2.0", false, Some("LLVM-exception")), + ])), + SpdxExpression::Or(BTreeSet::from([ + req("MIT", false, None), + req("Unlicense", false, None), + ])), + SpdxExpression::And(BTreeSet::from([ + req("CC0-1.0", false, None), + req("GPL-3.0", false, None), + ])), + req("LGPL-2.1", true, None), + ])) + .simplify(), + SpdxExpression::Or(BTreeSet::from([ + req("MIT", false, None), + req("Unlicense", false, None), + req("LGPL-2.1", true, None), + SpdxExpression::And(BTreeSet::from([ + req("MIT", false, None), + req("Apache-2.0", false, Some("LLVM-exception")), + ])), + SpdxExpression::And(BTreeSet::from([ + req("CC0-1.0", false, None), + req("GPL-3.0", false, None), + ])), + ])) + ); + } +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 94d76a3..55dcff4 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -7,6 +7,7 @@ mod item; mod item_class; mod jump; mod labels; +mod licensing; pub mod templates; use actix_identity::Identity; @@ -19,7 +20,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(item::config) .configure(item_class::config) .configure(jump::config) - .configure(labels::config); + .configure(labels::config) + .configure(licensing::config); } #[get("/")] diff --git a/src/frontend/templates/mod.rs b/src/frontend/templates/mod.rs index d63a2f5..d953e3a 100644 --- a/src/frontend/templates/mod.rs +++ b/src/frontend/templates/mod.rs @@ -59,7 +59,13 @@ fn footer() -> Markup { html! { footer .mt-auto.py-3.bg-body-tertiary.text-body-tertiary { div .container { - p .mb-0 { "li7y is free software, released under the terms of the AGPL v3" } + p .mb-0 { + "li7y is free software, released under the terms of the AGPL v3 (" + a href="https://git.sbruder.de/simon/li7y" { "source code" } + ", " + a href="/licensing" { "licensing of all dependencies" } + ")" + } } } } diff --git a/tests/licensing.rs b/tests/licensing.rs new file mode 100644 index 0000000..b34f184 --- /dev/null +++ b/tests/licensing.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use actix_web::{body::MessageBody, test}; +use sqlx::PgPool; + +#[allow(dead_code)] +mod common; + +#[sqlx::test(fixtures("default"))] +async fn is_linked(pool: PgPool) { + let srv = test::init_service(li7y::app(&common::config(), &pool)).await; + + let req = test::TestRequest::get().uri("/login").to_request(); + + let res = test::call_service(&srv, req).await; + + assert!(res.status().is_success()); + + let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap(); + + assert!(body.contains(r#""#)); +} + +#[sqlx::test(fixtures("default"))] +async fn contains_basic_information(pool: PgPool) { + let srv = test::init_service(li7y::app(&common::config(), &pool)).await; + + let req = test::TestRequest::get().uri("/licensing").to_request(); + + let res = test::call_service(&srv, req).await; + + assert!(res.status().is_success()); + + let body = String::from_utf8(res.into_body().try_into_bytes().unwrap().to_vec()).unwrap(); + + // crate names + assert!(body.contains(">actix-web<")); + assert!(body.contains(">sqlx-postgres<")); + + // author names (only me and entries without email address) + assert!(body.contains(">Simon Bruder <simon@sbruder.de><")); + assert!(body.contains(">RustCrypto Developers<")); + + // license links (only partial, as I don’t want to check class names) + assert!(body.contains(r##"href="#license-Apache-2.0">Apache-2.0<"##)); + assert!(body.contains(r##"href="#license-MIT">MIT<"##)); +}