Skip to main content

scallion/
store.rs

1use std::cmp::Ordering;
2use std::collections::HashMap;
3use std::ffi::OsStr;
4use std::fmt::{self, Display};
5use std::fs;
6use std::io::{self, Read, Write};
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9
10use log::{debug, info};
11use rmp_serde::Serializer;
12use serde::{Deserialize, Serialize};
13
14use crate::data::{LicenseType, MatchData};
15use crate::error::Error;
16
17pub(crate) const CACHE_VERSION: &[u8] = b"scallion-00";
18const HEADER_LENGTH: usize = CACHE_VERSION.len() + 5;
19
20/// Entry for a specific license in the license [`Store`].
21#[derive(Debug, Deserialize, Serialize)]
22pub struct LicenseEntry {
23    pub(crate) original: MatchData,
24    pub(crate) aliases: Vec<String>,
25    pub(crate) headers: Vec<MatchData>,
26    pub(crate) alternates: Vec<MatchData>,
27}
28
29impl LicenseEntry {
30    #[must_use]
31    pub(crate) const fn new(original: MatchData) -> LicenseEntry {
32        LicenseEntry {
33            original,
34            aliases: Vec::new(),
35            alternates: Vec::new(),
36            headers: Vec::new(),
37        }
38    }
39
40    /// Retrieve original text of this license.
41    #[must_use]
42    pub const fn original(&self) -> &MatchData {
43        &self.original
44    }
45
46    /// Retrieve list of aliases for this license.
47    #[must_use]
48    pub const fn aliases(&self) -> &[String] {
49        self.aliases.as_slice()
50    }
51
52    /// Retrieve alternate variants of this license.
53    #[must_use]
54    pub const fn variants(&self) -> &[MatchData] {
55        self.alternates.as_slice()
56    }
57
58    /// Retrieve header-only variants of this license.
59    #[must_use]
60    pub const fn headers(&self) -> &[MatchData] {
61        self.headers.as_slice()
62    }
63
64    /// Add alias of this license's canonical name.
65    pub fn add_alias(&mut self, name: String) {
66        self.aliases.push(name);
67    }
68
69    /// Add alternate format of this license.
70    pub fn add_variant(&mut self, data: MatchData) {
71        self.alternates.push(data);
72    }
73
74    /// Add header-only variant of this license.
75    pub fn add_header(&mut self, data: MatchData) {
76        self.headers.push(data);
77    }
78}
79
80/// A representation of a collection of known licenses.
81///
82/// This struct is generally what you want to start with if you're looking to
83/// match text against a database of licenses. Load a cache from disk using
84/// `from_cache`, then use the `analyze` function to determine what a text most
85/// closely matches.
86///
87/// # Examples
88///
89/// ```rust,no_run
90/// # use std::fs::File;
91/// # use std::error::Error;
92/// use scallion::{MatchData, Store};
93///
94/// # fn main() -> Result<(), Box<dyn Error>> {
95/// let store = Store::from_cache(File::open("cache.bin")?)?;
96/// let result = store.analyze(&MatchData::from("what's this"));
97/// # Ok(())
98/// # }
99/// ```
100#[derive(Debug, Default, Deserialize, Serialize)]
101pub struct Store {
102    licenses: HashMap<String, LicenseEntry>,
103}
104
105impl Store {
106    /// Create a new `Store`.
107    ///
108    /// More often, you probably want to use `from_cache` instead of creating
109    /// an empty store.
110    #[must_use]
111    pub fn new() -> Self {
112        Store {
113            licenses: HashMap::new(),
114        }
115    }
116
117    /// Get the number of licenses in the store.
118    ///
119    /// This only counts licenses by name -- headers, aliases, and alternates
120    /// aren't included in the count.
121    #[must_use]
122    pub fn len(&self) -> usize {
123        self.licenses.len()
124    }
125
126    /// Check if the store is empty.
127    #[must_use]
128    pub fn is_empty(&self) -> bool {
129        self.licenses.is_empty()
130    }
131
132    /// Add a single license to the store.
133    ///
134    /// If the license with the given name already existed, it and all of its
135    /// variants will be replaced in the store, and returned.
136    pub fn add_license(&mut self, name: String, data: MatchData) -> Option<LicenseEntry> {
137        let entry = LicenseEntry::new(data);
138        self.licenses.insert(name, entry)
139    }
140
141    /// Retrieve a single license entry from the store.
142    ///
143    /// Returns `None` If no license with the given name exists.
144    #[must_use]
145    pub fn get_license(&self, name: &str) -> Option<&LicenseEntry> {
146        self.licenses.get(name)
147    }
148
149    /// Retrieve a mutable reference to a license entry from the store.
150    ///
151    /// Returns `None` If no license with the given name exists.
152    #[must_use]
153    pub fn get_license_mut(&mut self, name: &str) -> Option<&mut LicenseEntry> {
154        self.licenses.get_mut(name)
155    }
156
157    /// Insert new unique license data or update an existing entry with a new alias.
158    ///
159    /// Returns the name of the existing license if only an alias was added,
160    /// or `None` if a new entry was added.
161    fn insert_or_add_alias(&mut self, name: &str, data: MatchData, header: Option<MatchData>) {
162        // check if an identical license is already present
163        let mut already_existed = None;
164        self.licenses.iter_mut().for_each(|(key, ref mut value)| {
165            if value.original.eq_data(&data) {
166                value.aliases.push(name.to_string());
167                already_existed = Some(key.as_str());
168            }
169        });
170        if let Some(prev) = already_existed {
171            info!("{name} already stored; added as an alias for {prev}");
172            return;
173        }
174
175        let license = self
176            .licenses
177            .entry(name.to_string())
178            .or_insert_with(|| LicenseEntry::new(data));
179
180        if let Some(header_text) = header {
181            license.headers = vec![header_text];
182        }
183    }
184
185    /// Compare the given `MatchData` against all licenses in the `Store`.
186    ///
187    /// This parallelizes the search as much as it can to find the best match.
188    /// Once a match is obtained, it can be optimized further; see methods on
189    /// `MatchData` for more information.
190    pub fn analyze<'a>(&'a self, text: &MatchData) -> Match<'a> {
191        let mut res: Vec<PartialMatch<'a>>;
192
193        let analyze_fold = |mut acc: Vec<PartialMatch<'a>>, (name, data): (&'a String, &'a LicenseEntry)| {
194            acc.push(PartialMatch {
195                score: data.original.match_score(text),
196                name,
197                license_type: LicenseType::Original,
198                data: &data.original,
199            });
200            data.alternates.iter().for_each(|alt| {
201                acc.push(PartialMatch {
202                    score: alt.match_score(text),
203                    name,
204                    license_type: LicenseType::Alternate,
205                    data: alt,
206                });
207            });
208            data.headers.iter().for_each(|head| {
209                acc.push(PartialMatch {
210                    score: head.match_score(text),
211                    name,
212                    license_type: LicenseType::Header,
213                    data: head,
214                });
215            });
216            acc
217        };
218
219        // parallel analysis
220        #[cfg(not(target_arch = "wasm32"))]
221        {
222            use rayon::prelude::*;
223            res = self.licenses.par_iter().fold(Vec::new, analyze_fold).reduce(
224                Vec::new,
225                |mut a: Vec<PartialMatch<'a>>, b: Vec<PartialMatch<'a>>| {
226                    a.extend(b);
227                    a
228                },
229            );
230            res.par_sort_unstable_by(|a, b| b.partial_cmp(a).unwrap());
231        }
232
233        // single-threaded analysis
234        #[cfg(target_arch = "wasm32")]
235        {
236            res = self
237                .licenses
238                .iter()
239                // len of licenses isn't strictly correct, but it'll do
240                .fold(Vec::with_capacity(self.licenses.len()), analyze_fold);
241            res.sort_unstable_by(|a, b| b.partial_cmp(a).unwrap());
242        }
243
244        let m = &res[0];
245
246        Match {
247            score: m.score,
248            name: m.name,
249            license_type: m.license_type,
250            data: m.data,
251        }
252    }
253
254    /// Create a store from a cache file.
255    ///
256    /// This method is highly useful for quickly loading a cache, as creating
257    /// one from text data is rather slow. This method can typically load
258    /// the full SPDX set from disk in 200-300 ms. The cache will be
259    /// sanity-checked to ensure it was generated with a similar version of
260    /// scallion.
261    pub fn from_cache<R>(mut readable: R) -> Result<Store, Error>
262    where
263        R: Read + Sized,
264    {
265        let mut header = [0u8; HEADER_LENGTH];
266        readable.read_exact(&mut header).map_err(|e| Error::io(e, None))?;
267
268        let cf = match &header {
269            b"scallion-00-zstd" => CF::Zstd,
270            b"scallion-00-gzip" => CF::Gzip,
271            b"scallion-00-none" => CF::None,
272            _ => return Err(Error::cache_version(header.to_vec())),
273        };
274
275        match cf {
276            CF::Zstd => {
277                #[cfg(feature = "zstd")]
278                {
279                    let dec = zstd::Decoder::new(readable).map_err(|e| Error::io(e, None))?;
280                    let store = rmp_serde::decode::from_read(dec)?;
281                    Ok(store)
282                }
283                #[cfg(not(feature = "zstd"))]
284                {
285                    Err(Error::cache_format(cf))
286                }
287            },
288            CF::Gzip => {
289                #[cfg(feature = "gzip")]
290                {
291                    let dec = flate2::read::GzDecoder::new(readable);
292                    let store = rmp_serde::decode::from_read(dec)?;
293                    Ok(store)
294                }
295                #[cfg(not(feature = "gzip"))]
296                {
297                    Err(Error::cache_format(cf))
298                }
299            },
300            CF::None => {
301                let store = rmp_serde::decode::from_read(readable)?;
302                Ok(store)
303            },
304        }
305    }
306
307    /// Serialize the current store.
308    ///
309    /// The output will be a `MessagePack`'d gzip'd or zstd'd binary stream that should be
310    /// written to disk.
311    pub fn to_cache<W>(&self, mut writable: W, format: CF) -> Result<(), Error>
312    where
313        W: Write + Sized,
314    {
315        let serialize = || -> Result<Vec<u8>, Error> {
316            // This currently sits around 3.7MiB, so go up to 4 to fit comfortably
317            let mut buf = Vec::with_capacity(4 * 1024 * 1024);
318            let mut serializer = Serializer::new(&mut buf);
319            self.serialize(&mut serializer)?;
320            Ok(buf)
321        };
322
323        match format {
324            CF::Zstd => {
325                #[cfg(feature = "zstd")]
326                {
327                    writable.write_all(CACHE_VERSION).map_err(|e| Error::io(e, None))?;
328                    writable.write_all(b"-zstd").map_err(|e| Error::io(e, None))?;
329
330                    let serialized = serialize()?;
331                    let mut enc = zstd::Encoder::new(writable, 21).map_err(|e| Error::io(e, None))?;
332
333                    io::copy(&mut serialized.as_slice(), &mut enc).map_err(|e| Error::io(e, None))?;
334                    enc.finish().map_err(|e| Error::io(e, None))?;
335                    Ok(())
336                }
337                #[cfg(not(feature = "zstd"))]
338                {
339                    Err(Error::cache_format(format))
340                }
341            },
342            CF::Gzip => {
343                #[cfg(feature = "gzip")]
344                {
345                    writable.write_all(CACHE_VERSION).map_err(|e| Error::io(e, None))?;
346                    writable.write_all(b"-gzip").map_err(|e| Error::io(e, None))?;
347
348                    let serialized = serialize()?;
349                    let mut enc = flate2::write::GzEncoder::new(writable, flate2::Compression::default());
350
351                    io::copy(&mut serialized.as_slice(), &mut enc).map_err(|e| Error::io(e, None))?;
352                    enc.finish().map_err(|e| Error::io(e, None))?;
353                    Ok(())
354                }
355                #[cfg(not(feature = "gzip"))]
356                {
357                    Err(Error::cache_format(format))
358                }
359            },
360            CF::None => {
361                writable.write_all(CACHE_VERSION).map_err(|e| Error::io(e, None))?;
362                writable.write_all(b"-none").map_err(|e| Error::io(e, None))?;
363
364                let serialized = serialize()?;
365
366                io::copy(&mut serialized.as_slice(), &mut writable).map_err(|e| Error::io(e, None))?;
367                Ok(())
368            },
369        }
370    }
371
372    /// Fill the store with SPDX JSON data.
373    ///
374    /// This function is very specific to the format of SPDX's
375    /// `license-list-data` repository. It reads all JSON files in the
376    /// `json/details` directory and creates entries inside the store for
377    /// matching.
378    pub fn load_spdx<P: AsRef<Path>>(&mut self, dir: P) -> Result<(), Error> {
379        let paths = locate_json_files(dir)?;
380
381        for path in paths {
382            let parsed = parse_json_file(&path)?;
383
384            if parsed.deprecated {
385                debug!("Skipping {} (deprecated)", parsed.name);
386                continue;
387            }
388            info!("Processing {}", parsed.name);
389
390            let data = MatchData::new(&parsed.text);
391            let header = parsed.header.as_deref().map(MatchData::new);
392
393            self.insert_or_add_alias(&parsed.name, data, header);
394        }
395
396        Ok(())
397    }
398}
399
400/// License data cache compression format.
401#[derive(Clone, Copy, Debug, PartialEq)]
402pub enum CompressionFormat {
403    /// zstd compression (using libzstd)
404    Zstd,
405    /// gzip compression (using `zlib-rs`, or `miniz_oxide` on WASM)
406    Gzip,
407    /// no compression
408    None,
409}
410
411use CompressionFormat as CF;
412
413impl Display for CompressionFormat {
414    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415        match self {
416            CF::Zstd => write!(f, "zstd"),
417            CF::Gzip => write!(f, "gzip"),
418            CF::None => write!(f, "none"),
419        }
420    }
421}
422
423impl FromStr for CompressionFormat {
424    type Err = String;
425
426    fn from_str(s: &str) -> Result<Self, Self::Err> {
427        match s {
428            "zstd" => Ok(CF::Zstd),
429            "gzip" => Ok(CF::Gzip),
430            "none" => Ok(CF::None),
431            _ => Err(format!("Invalid compression format: '{s}'")),
432        }
433    }
434}
435
436/// Information about text that was compared against licenses in the store.
437///
438/// This only contains information about the overall match; to uncover more
439/// data you can run methods like `optimize_bounds` on `MatchData`.
440///
441/// Its lifetime is tied to the lifetime of the `Store` it was generated from.
442#[derive(Clone)]
443pub struct Match<'a> {
444    /// Confidence score of the match, ranging from 0 to 1.
445    pub score: f32,
446    /// The name of the closest matching license in the `Store`. This will
447    /// always be something that exists in the store, regardless of the score.
448    pub name: &'a str,
449    /// The type of the license that matched. Useful to know if the match was
450    /// the complete text, a header, or something else.
451    pub license_type: LicenseType,
452    /// A reference to the license data that matched inside the `Store`. May be
453    /// useful for diagnostic purposes or to further optimize the result.
454    pub data: &'a MatchData,
455}
456
457/// A lighter version of Match to be used during analysis.
458/// Reduces the need for cloning a bunch of fields.
459struct PartialMatch<'a> {
460    pub name: &'a str,
461    pub score: f32,
462    pub license_type: LicenseType,
463    pub data: &'a MatchData,
464}
465
466impl PartialOrd for PartialMatch<'_> {
467    fn partial_cmp(&self, other: &PartialMatch<'_>) -> Option<Ordering> {
468        self.score.partial_cmp(&other.score)
469    }
470}
471
472impl PartialEq for PartialMatch<'_> {
473    fn eq(&self, other: &PartialMatch<'_>) -> bool {
474        self.score.eq(&other.score) && self.name == other.name && self.license_type == other.license_type
475    }
476}
477
478impl fmt::Debug for Match<'_> {
479    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
480        write!(
481            f,
482            "Match {{ score: {}, name: {}, license_type: {:?} }}",
483            self.score, self.name, self.license_type
484        )
485    }
486}
487
488fn locate_json_files<P: AsRef<Path>>(dir: P) -> Result<Vec<PathBuf>, Error> {
489    // locate all json files in the directory
490    let mut paths: Vec<_> = fs::read_dir(&dir)
491        .map_err(|e| Error::io(e, Some(dir.as_ref().to_path_buf())))?
492        .filter_map(Result::ok)
493        .map(|e| e.path())
494        .filter(|p| p.is_file() && p.extension().unwrap_or_else(|| OsStr::new("")) == "json")
495        .collect();
496
497    // sort without extensions; otherwise dashes and dots muck it up
498    paths.sort_by(|a, b| a.file_stem().unwrap().cmp(b.file_stem().unwrap()));
499
500    Ok(paths)
501}
502
503#[derive(Deserialize)]
504struct LicenseListData {
505    #[serde(rename = "licenseId")]
506    name: String,
507    #[serde(rename = "isDeprecatedLicenseId")]
508    deprecated: bool,
509    #[serde(rename = "licenseText")]
510    text: String,
511    #[serde(rename = "standardLicenseHeader")]
512    header: Option<String>,
513    // incomplete
514}
515
516fn parse_json_file<P: AsRef<Path>>(path: P) -> Result<LicenseListData, Error> {
517    let path = path.as_ref().to_path_buf();
518    let data = fs::read_to_string(&path).map_err(|e| Error::io(e, Some(path.clone())))?;
519    serde_json::from_str(&data).map_err(|e| Error::spdx(e, path))
520}