diff --git a/src/expression/parser.rs b/src/expression/parser.rs index e9b3402..25600c3 100644 --- a/src/expression/parser.rs +++ b/src/expression/parser.rs @@ -1,5 +1,5 @@ use crate::{ - LicenseItem, LicenseReq, ParseMode, + AdditionItem, LicenseItem, LicenseReq, ParseMode, error::{ParseError, Reason}, expression::{ExprNode, Expression, ExpressionReq, Operator}, lexer::{Lexer, Token}, @@ -114,6 +114,16 @@ impl Expression { can.push_str("LicenseRef-"); can.push_str(lic_ref); } + Token::AdditionRef { doc_ref, add_ref } => { + if let Some(dr) = doc_ref { + can.push_str("DocumentRef-"); + can.push_str(dr); + can.push(':'); + } + + can.push_str("AdditionRef-"); + can.push_str(add_ref); + } } } @@ -177,10 +187,10 @@ impl Expression { let expected: &[&str] = match last_token { None | Some(Token::And | Token::Or | Token::OpenParen) => &["", "("], Some(Token::CloseParen) => &["AND", "OR"], - Some(Token::Exception(_)) => &["AND", "OR", ")"], + Some(Token::Exception(_) | Token::AdditionRef { .. }) => &["AND", "OR", ")"], Some(Token::Spdx(_)) => &["AND", "OR", "WITH", ")", "+"], Some(Token::LicenseRef { .. } | Token::Plus) => &["AND", "OR", "WITH", ")"], - Some(Token::With) => &[""], + Some(Token::With) => &[""], }; Err(ParseError { @@ -219,7 +229,7 @@ impl Expression { doc_ref: doc_ref.map(String::from), lic_ref: String::from(*lic_ref), }, - exception: None, + addition: None, }, span: lt.span.start as u32..lt.span.end as u32, })); @@ -281,6 +291,7 @@ impl Expression { | Token::LicenseRef { .. } | Token::CloseParen | Token::Exception(_) + | Token::AdditionRef { .. } | Token::Plus, ) => { let new_op = match lt.token { @@ -330,6 +341,7 @@ impl Expression { | Token::LicenseRef { .. } | Token::Plus | Token::Exception(_) + | Token::AdditionRef { .. } | Token::CloseParen, ) => { while let Some(top) = op_stack.pop() { @@ -357,7 +369,19 @@ impl Expression { Token::Exception(exc) => match last_token { Some(Token::With) => match expr_queue.last_mut() { Some(ExprNode::Req(lic)) => { - lic.req.exception = Some(*exc); + lic.req.addition = Some(AdditionItem::Spdx(*exc)); + } + _ => unreachable!(), + }, + _ => return make_err_for_token(last_token, lt.span), + }, + Token::AdditionRef { doc_ref, add_ref } => match last_token { + Some(Token::With) => match expr_queue.last_mut() { + Some(ExprNode::Req(lic)) => { + lic.req.addition = Some(AdditionItem::Other { + doc_ref: doc_ref.map(String::from), + add_ref: String::from(*add_ref), + }); } _ => unreachable!(), }, @@ -374,6 +398,7 @@ impl Expression { Token::Spdx(_) | Token::LicenseRef { .. } | Token::Exception(_) + | Token::AdditionRef { .. } | Token::CloseParen | Token::Plus, ) => {} diff --git a/src/lexer.rs b/src/lexer.rs index 5cbf7d0..70bb4d4 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -75,6 +75,11 @@ pub enum Token<'a> { }, /// A recognized SPDX exception id Exception(ExceptionId), + /// A `AdditionRef-` prefixed id, with an optional `DocumentRef-` + AdditionRef { + doc_ref: Option<&'a str>, + add_ref: &'a str, + }, /// A postfix `+` indicating "or later" for a particular SPDX license id Plus, /// A `(` for starting a group @@ -111,6 +116,13 @@ impl Token<'_> { }) + "LicenseRef-".len() + lic_ref.len() } + Token::AdditionRef { doc_ref, add_ref } => { + doc_ref.map_or(0, |d| { + // +1 is for the `:` + "DocumentRef-".len() + d.len() + 1 + }) + "AdditionRef-".len() + + add_ref.len() + } } } } @@ -175,6 +187,12 @@ impl<'a> Lexer<'a> { }) } + /// Return a document ref if found - equivalent to the regex `^DocumentRef-([-a-zA-Z0-9.]+)` + #[inline] + fn find_document_ref(text: &'a str) -> Option<&'a str> { + Self::find_ref("DocumentRef-", text) + } + /// Return a license ref if found - equivalent to the regex `^LicenseRef-([-a-zA-Z0-9.]+)` #[inline] fn find_license_ref(text: &'a str) -> Option<&'a str> { @@ -185,10 +203,25 @@ impl<'a> Lexer<'a> { /// equivalent to the regex `^DocumentRef-([-a-zA-Z0-9.]+):LicenseRef-([-a-zA-Z0-9.]+)` fn find_document_and_license_ref(text: &'a str) -> Option<(&'a str, &'a str)> { let split = text.split_once(':'); - let doc_ref = split.and_then(|(doc, _)| Self::find_ref("DocumentRef-", doc)); + let doc_ref = split.and_then(|(doc, _)| Self::find_document_ref(doc)); let lic_ref = split.and_then(|(_, lic)| Self::find_license_ref(lic)); Option::zip(doc_ref, lic_ref) } + + /// Return an addition ref if found - equivalent to the regex `^AdditionRef-([-a-zA-Z0-9.]+)` + #[inline] + fn find_addition_ref(text: &'a str) -> Option<&'a str> { + Self::find_ref("AdditionRef-", text) + } + + /// Return a document ref and license ref if found, + /// equivalent to the regex `^DocumentRef-([-a-zA-Z0-9.]+):AdditionRef-([-a-zA-Z0-9.]+)` + fn find_document_and_addition_ref(text: &'a str) -> Option<(&'a str, &'a str)> { + let split = text.split_once(':'); + let doc_ref = split.and_then(|(doc, _)| Self::find_document_ref(doc)); + let lic_ref = split.and_then(|(_, add)| Self::find_addition_ref(add)); + Option::zip(doc_ref, lic_ref) + } } /// A wrapper around a particular token that includes the span of the characters @@ -265,6 +298,18 @@ impl<'a> Iterator for Lexer<'a> { doc_ref: None, lic_ref, }) + } else if let Some((doc_ref, add_ref)) = + Lexer::find_document_and_addition_ref(m) + { + ok_token(Token::AdditionRef { + doc_ref: Some(doc_ref), + add_ref, + }) + } else if let Some(add_ref) = Lexer::find_addition_ref(m) { + ok_token(Token::AdditionRef { + doc_ref: None, + add_ref, + }) } else if let Some((lic_id, token_len)) = if self.mode.allow_imprecise_license_names { crate::imprecise_license_id(self.inner) diff --git a/src/lib.rs b/src/lib.rs index 711c7c7..1af0890 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -239,9 +239,9 @@ impl fmt::Debug for ExceptionId { pub struct LicenseReq { /// The license pub license: LicenseItem, - /// The exception allowed for this license, as specified following + /// The additional text for this license, as specified following /// the `WITH` operator - pub exception: Option, + pub addition: Option, } impl From for LicenseReq { @@ -251,7 +251,7 @@ impl From for LicenseReq { id, or_later: false, }, - exception: None, + addition: None, } } } @@ -260,8 +260,8 @@ impl fmt::Display for LicenseReq { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { self.license.fmt(f)?; - if let Some(ref exe) = self.exception { - write!(f, " WITH {}", exe.name)?; + if let Some(ref exe) = self.addition { + write!(f, " WITH {exe}")?; } Ok(()) @@ -398,6 +398,110 @@ impl fmt::Display for LicenseItem { } } +/// A single addition term in a addition expression, according to the SPDX spec. +/// +/// This can be either an SPDX license exception, which is mapped to a [`ExceptionId`] +/// from a valid SPDX short identifier, or else a document and/or addition ref +#[derive(Debug, Clone, Eq)] +pub enum AdditionItem { + /// A regular SPDX license exception id + Spdx(ExceptionId), + Other { + /// Purpose: Identify any external SPDX documents referenced within this SPDX document. + /// See the [spec](https://spdx.org/spdx-specification-21-web-version#h.h430e9ypa0j9) for + /// more details. + doc_ref: Option, + /// Purpose: Provide a locally unique identifier to refer to additional text that are not found on the SPDX License List. + /// See the [spec](https://spdx.org/spdx-specification-21-web-version#h.4f1mdlm) for + /// more details. + add_ref: String, + }, +} + +impl AdditionItem { + /// Returns the license exception identifier, if it is a recognized SPDX license exception + /// and not a license exception referencer + #[must_use] + pub fn id(&self) -> Option { + match self { + Self::Spdx(id) => Some(*id), + Self::Other { .. } => None, + } + } +} + +impl Ord for AdditionItem { + fn cmp(&self, o: &Self) -> Ordering { + match (self, o) { + (Self::Spdx(a), Self::Spdx(b)) => match a.cmp(b) { + Ordering::Equal => a.cmp(b), + o => o, + }, + ( + Self::Other { + doc_ref: ad, + add_ref: aa, + }, + Self::Other { + doc_ref: bd, + add_ref: ba, + }, + ) => match ad.cmp(bd) { + Ordering::Equal => aa.cmp(ba), + o => o, + }, + (Self::Spdx(_), Self::Other { .. }) => Ordering::Less, + (Self::Other { .. }, Self::Spdx(_)) => Ordering::Greater, + } + } +} + +impl PartialOrd for AdditionItem { + #[allow(clippy::non_canonical_partial_ord_impl)] + fn partial_cmp(&self, o: &Self) -> Option { + match (self, o) { + (Self::Spdx(a), Self::Spdx(b)) => a.partial_cmp(b), + ( + Self::Other { + doc_ref: ad, + add_ref: aa, + }, + Self::Other { + doc_ref: bd, + add_ref: ba, + }, + ) => match ad.cmp(bd) { + Ordering::Equal => aa.partial_cmp(ba), + o => Some(o), + }, + (Self::Spdx(_), Self::Other { .. }) => Some(cmp::Ordering::Less), + (Self::Other { .. }, Self::Spdx(_)) => Some(cmp::Ordering::Greater), + } + } +} + +impl PartialEq for AdditionItem { + fn eq(&self, o: &Self) -> bool { + matches!(self.partial_cmp(o), Some(cmp::Ordering::Equal)) + } +} + +impl fmt::Display for AdditionItem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { + match self { + AdditionItem::Spdx(id) => id.name.fmt(f), + AdditionItem::Other { + doc_ref: Some(d), + add_ref: a, + } => write!(f, "DocumentRef-{d}:AdditionRef-{a}"), + AdditionItem::Other { + doc_ref: None, + add_ref: a, + } => write!(f, "AdditionRef-{a}"), + } + } +} + /// Attempts to find a [`LicenseId`] for the string. /// /// Note that any `+` at the end is trimmed when searching for a match. diff --git a/src/licensee.rs b/src/licensee.rs index 41a719f..cccfd7e 100644 --- a/src/licensee.rs +++ b/src/licensee.rs @@ -1,11 +1,11 @@ use crate::{ - ExceptionId, LicenseItem, LicenseReq, + AdditionItem, LicenseItem, LicenseReq, error::{ParseError, Reason}, lexer::{Lexer, Token}, }; use std::fmt; -/// A convenience wrapper for a license and optional exception that can be +/// A convenience wrapper for a license and optional additional text that can be /// checked against a license requirement to see if it satisfies the requirement /// placed by a license holder /// @@ -40,13 +40,13 @@ impl Licensee { /// Note that use of SPDX's `or_later` is completely ignored for licensees /// as it only applies to the license holder(s), not the licensee #[must_use] - pub fn new(license: LicenseItem, exception: Option) -> Self { + pub fn new(license: LicenseItem, addition: Option) -> Self { if let LicenseItem::Spdx { or_later, .. } = &license { debug_assert!(!or_later); } Self { - inner: LicenseReq { license, exception }, + inner: LicenseReq { license, addition }, } } @@ -57,8 +57,8 @@ impl Licensee { } /// Parses an simplified version of an SPDX license expression that can - /// contain at most 1 valid SPDX license with an optional exception joined - /// by a `WITH`. + /// contain at most 1 valid SPDX license with an optional additional text + /// joined by a `WITH`. /// /// ``` /// use spdx::Licensee; @@ -67,12 +67,19 @@ impl Licensee { /// Licensee::parse("MIT").unwrap(); /// /// // SPDX allows license identifiers outside of the official license list - /// // via the LicenseRef- prefix + /// // via the LicenseRef- prefix (with optional DocumentRef- prefix) /// Licensee::parse("LicenseRef-My-Super-Extra-Special-License").unwrap(); + /// Licensee::parse("DocumentRef-mydoc:LicenseRef-My-License").unwrap(); /// /// // License and exception /// Licensee::parse("Apache-2.0 WITH LLVM-exception").unwrap(); /// + /// // SPDX allows license with additional text outside of the official + /// // license exception list via the AdditionRef- prefix (with optional + /// // DocumentRef- prefix) + /// Licensee::parse("MIT WITH AdditionRef-My-Exception").unwrap(); + /// Licensee::parse("MIT WITH DocumentRef-mydoc:AdditionRef-My-Exception").unwrap(); + /// /// // `+` is only allowed to be used by license requirements from the license holder /// Licensee::parse("Apache-2.0+").unwrap_err(); /// @@ -117,7 +124,7 @@ impl Licensee { } }; - let exception = match lexer.next() { + let addition = match lexer.next() { None => None, Some(lt) => { let lt = lt?; @@ -130,12 +137,16 @@ impl Licensee { })??; match lt.token { - Token::Exception(exc) => Some(exc), + Token::Exception(id) => Some(AdditionItem::Spdx(id)), + Token::AdditionRef { doc_ref, add_ref } => Some(AdditionItem::Other { + doc_ref: doc_ref.map(String::from), + add_ref: add_ref.to_owned(), + }), _ => { return Err(ParseError { original: original.to_owned(), span: lt.span, - reason: Reason::Unexpected(&[""]), + reason: Reason::Unexpected(&[""]), }); } } @@ -152,12 +163,12 @@ impl Licensee { }; Ok(Self { - inner: LicenseReq { license, exception }, + inner: LicenseReq { license, addition }, }) } /// Determines whether the specified license requirement is satisfied by - /// this license (+exception) + /// this license (+addition) /// /// ``` /// let licensee = spdx::Licensee::parse("Apache-2.0 WITH LLVM-exception").unwrap(); @@ -168,7 +179,8 @@ impl Licensee { /// // Means the license holder is fine with Apache-2.0 or higher /// or_later: true, /// }, - /// exception: spdx::exception_id("LLVM-exception"), + /// addition: spdx::exception_id("LLVM-exception") + /// .map(spdx::AdditionItem::Spdx), /// })); /// ``` #[must_use] @@ -224,7 +236,7 @@ impl Licensee { _ => return false, } - req.exception == self.inner.exception + req.addition == self.inner.addition } #[must_use] @@ -256,7 +268,7 @@ impl AsRef for Licensee { #[cfg(test)] mod test { - use crate::{LicenseItem, LicenseReq, Licensee, exception_id, license_id}; + use crate::{AdditionItem, LicenseItem, LicenseReq, Licensee, exception_id, license_id}; const LICENSEES: &[&str] = &[ "LicenseRef-Embark-Proprietary", @@ -274,6 +286,7 @@ mod test { "Unicode-DFS-2016", "Unlicense", "Apache-2.0", + "Apache-2.0 WITH AdditionRef-Embark-Exception", ]; #[test] @@ -290,7 +303,7 @@ mod test { id: mpl_id, or_later: true, }, - exception: None, + addition: None, }; // Licensees can't have the `or_later` @@ -322,7 +335,7 @@ mod test { id: apache_id, or_later: false, }, - exception: Some(llvm_exc), + addition: Some(AdditionItem::Spdx(llvm_exc)), }; assert_eq!( @@ -347,7 +360,7 @@ mod test { doc_ref: None, lic_ref: "Embark-Proprietary".to_owned(), }, - exception: None, + addition: None, }; assert_eq!( @@ -374,7 +387,7 @@ mod test { id: lic_id, or_later: true, }, - exception: None, + addition: None, }; // Licensees can't have the `or_later` diff --git a/tests/check.rs b/tests/check.rs index 9fa6baf..ab83c66 100644 --- a/tests/check.rs +++ b/tests/check.rs @@ -254,7 +254,7 @@ fn gpl_pedantic() { id: spdx::license_id(col).unwrap(), or_later: false, }, - exception: None, + addition: None, }; exp.push_str(if *passes { "+" } else { "-" }); @@ -391,7 +391,7 @@ fn minimizes_vanilla() { id: spdx::license_id("Apache-2.0").unwrap(), or_later: false, }, - exception: None, + addition: None, }] ); @@ -407,7 +407,7 @@ fn minimizes_vanilla() { id: spdx::license_id("MIT").unwrap(), or_later: false, }, - exception: None, + addition: None, }] ); } @@ -430,21 +430,21 @@ fn handles_unminimizable() { id: spdx::license_id("ISC").unwrap(), or_later: false, }, - exception: None, + addition: None, }, spdx::LicenseReq { license: LicenseItem::Spdx { id: spdx::license_id("OpenSSL").unwrap(), or_later: false, }, - exception: None, + addition: None, }, spdx::LicenseReq { license: LicenseItem::Spdx { id: spdx::license_id("MIT").unwrap(), or_later: false, }, - exception: None, + addition: None, } ] ); @@ -468,21 +468,21 @@ fn handles_complicated() { id: spdx::license_id("Apache-2.0").unwrap(), or_later: false, }, - exception: None, + addition: None, }, spdx::LicenseReq { license: LicenseItem::Spdx { id: spdx::license_id("ISC").unwrap(), or_later: false, }, - exception: None, + addition: None, }, spdx::LicenseReq { license: LicenseItem::Spdx { id: spdx::license_id("OpenSSL").unwrap(), or_later: false, }, - exception: None, + addition: None, }, ] ); @@ -502,21 +502,21 @@ fn handles_complicated() { id: spdx::license_id("MIT").unwrap(), or_later: false, }, - exception: None, + addition: None, }, spdx::LicenseReq { license: LicenseItem::Spdx { id: spdx::license_id("ISC").unwrap(), or_later: false, }, - exception: None, + addition: None, }, spdx::LicenseReq { license: LicenseItem::Spdx { id: spdx::license_id("OpenSSL").unwrap(), or_later: false, }, - exception: None, + addition: None, }, ] ); diff --git a/tests/lexer.rs b/tests/lexer.rs index 15b892a..04972ce 100644 --- a/tests/lexer.rs +++ b/tests/lexer.rs @@ -25,7 +25,7 @@ macro_rules! exc_tok { #[test] fn lexes_all_the_things() { - let text = "MIT+ OR () Apache-2.0 WITH AND LicenseRef-World Classpath-exception-2.0 DocumentRef-Test:LicenseRef-Hello"; + let text = "MIT+ OR () Apache-2.0 WITH AND LicenseRef-World Classpath-exception-2.0 DocumentRef-Test:LicenseRef-Hello AdditionRef-add1 DocumentRef-add-doc:AdditionRef-add2"; test_lex!( text, @@ -47,6 +47,14 @@ fn lexes_all_the_things() { doc_ref: Some("Test"), lic_ref: "Hello", }, + Token::AdditionRef { + doc_ref: None, + add_ref: "add1", + }, + Token::AdditionRef { + doc_ref: Some("add-doc"), + add_ref: "add2", + }, ] ); } diff --git a/tests/validation.rs b/tests/validation.rs index 2cbdf8b..ce01bf3 100644 --- a/tests/validation.rs +++ b/tests/validation.rs @@ -71,17 +71,17 @@ fn fails_unbalanced_parens() { #[test] fn fails_bad_exception() { - err!("Apache-2.0 WITH WITH LLVM-exception OR Apache-2.0" => &[""]; 16..20); - err!("Apache-2.0 WITH WITH LLVM-exception" => &[""]; 16..20); + err!("Apache-2.0 WITH WITH LLVM-exception OR Apache-2.0" => &[""]; 16..20); + err!("Apache-2.0 WITH WITH LLVM-exception" => &[""]; 16..20); err!("(Apache-2.0) WITH LLVM-exception" => &["AND", "OR"]; 13..17); err!("Apache-2.0 (WITH LLVM-exception)" => &["AND", "OR", "WITH", ")", "+"]; 11..12); - err!("(Apache-2.0 WITH) LLVM-exception" => &[""]; 16..17); - err!("(Apache-2.0 WITH)+ LLVM-exception" => &[""]; 16..17); - err!("Apache-2.0 WITH MIT" => &[""]; 16..19); - err!("Apache-2.0 WITH WITH MIT" => &[""]; 16..20); + err!("(Apache-2.0 WITH) LLVM-exception" => &[""]; 16..17); + err!("(Apache-2.0 WITH)+ LLVM-exception" => &[""]; 16..17); + err!("Apache-2.0 WITH MIT" => &[""]; 16..19); + err!("Apache-2.0 WITH WITH MIT" => &[""]; 16..20); err!("Apache-2.0 AND WITH MIT" => &["", "("]; 15..19); - err!("Apache-2.0 WITH AND MIT" => &[""]; 16..19); - err!("Apache-2.0 WITH" => &[""]; 15..15); + err!("Apache-2.0 WITH AND MIT" => &[""]; 16..19); + err!("Apache-2.0 WITH" => &[""]; 15..15); } #[test] @@ -97,8 +97,9 @@ fn fails_bad_plus() { err!("LicenseRef-Nope+" => &["AND", "OR", "WITH", ")"]; 15..16); err!("LAL-1.2 AND+" => &["", "("]; 11..12); err!("LAL-1.2 OR +" => SeparatedPlus @ 10..11); - err!("LAL-1.2 WITH+ LLVM-exception" => &[""]; 12..13); + err!("LAL-1.2 WITH+ LLVM-exception" => &[""]; 12..13); err!("LAL-1.2 WITH LLVM-exception+" => &["AND", "OR", ")"]; 27..28); + err!("LAL-1.2 WITH AdditionRef-myexc+" => &["AND", "OR", ")"]; 30..31); } #[test] @@ -111,7 +112,7 @@ fn fails_bad_ops() { err!("(MIT-advertising AND) MIT" => &["", "("]; 20..21); err!("MIT-advertising (AND MIT)" => &["AND", "OR", "WITH", ")", "+"]; 16..17); err!("OR MIT-advertising" => &["", "("]; 0..2); - err!("MIT-advertising WITH AND" => &[""]; 21..24); + err!("MIT-advertising WITH AND" => &[""]; 21..24); } #[test] @@ -136,9 +137,13 @@ fn validates_canonical() { #[test] fn validates_single_with_exception() { let with_exception = "Apache-2.0 WITH LLVM-exception"; + let addition_ref = "MPL-2.0 WITH AdditionRef-Embark-Exception"; + let doc_addition_ref = "MIT WITH DocumentRef-Embark:AdditionRef-Embark-Exception"; test_validate!(ok [ with_exception => [with_exception], + addition_ref => [addition_ref], + doc_addition_ref => [doc_addition_ref], ]); }