Skip to main content

provenant/license_detection/
hash_match.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Hash-based exact matching for license detection.
8//!
9//! This module implements the hash matching strategy which computes a hash of the
10//! entire query token sequence and looks for exact matches in the index.
11
12use sha1::{Digest, Sha1};
13
14use crate::license_detection::index::LicenseIndex;
15use crate::license_detection::index::dictionary::{TokenId, TokenKind};
16use crate::license_detection::models::position_span::PositionSpan;
17use crate::license_detection::models::{LicenseMatch, MatchCoordinates, MatcherKind};
18use crate::license_detection::query::QueryRun;
19use crate::models::LineNumber;
20use crate::models::MatchScore;
21
22pub const MATCH_HASH: MatcherKind = MatcherKind::Hash;
23
24/// Compute a SHA1 hash of a token sequence.
25///
26/// Converts token IDs to signed 16-bit integers (matching Python's `array('h')`),
27/// serializes them as little-endian bytes, and computes the SHA1 hash.
28///
29/// # Arguments
30/// * `tokens` - Slice of token IDs
31///
32/// # Returns
33/// 20-byte SHA1 digest
34///
35/// Corresponds to Python: `tokens_hash()` (lines 44-49)
36pub fn compute_hash(tokens: &[TokenId]) -> [u8; 20] {
37    let mut hasher = Sha1::new();
38
39    for token in tokens {
40        let signed = token.raw() as i16;
41        hasher.update(signed.to_le_bytes());
42    }
43
44    hasher.finalize().into()
45}
46
47/// Perform hash-based matching for a query run.
48///
49/// Computes the hash of the query token sequence and looks for exact matches
50/// in the index. If found, returns a single LicenseMatch with 100% coverage.
51///
52/// # Arguments
53/// * `index` - The license index
54/// * `query_run` - The query run to match
55///
56/// # Returns
57/// Vector of matches (0 or 1 match)
58///
59/// Corresponds to Python: `hash_match()` (lines 59-87)
60pub fn hash_match(index: &LicenseIndex, query_run: &QueryRun) -> Vec<LicenseMatch> {
61    let mut matches = Vec::new();
62    let query_hash = compute_hash(query_run.tokens());
63
64    if let Some(rid) = index.rid_by_hash.get(&query_hash) {
65        let Some(rule) = index.rule(*rid) else {
66            return matches;
67        };
68        let Some(itokens) = index.rule_tokens(*rid) else {
69            return matches;
70        };
71
72        let rule_length = rule.tokens.len();
73
74        let matched_length = query_run.tokens().len();
75        let match_coverage = 100.0;
76
77        let start_line = query_run
78            .line_for_pos(query_run.start)
79            .and_then(LineNumber::new)
80            .unwrap_or(LineNumber::ONE);
81        let end_line = if let Some(end) = query_run.end {
82            query_run
83                .line_for_pos(end)
84                .and_then(LineNumber::new)
85                .unwrap_or(start_line)
86        } else {
87            start_line
88        };
89
90        let end = query_run.end.unwrap_or(query_run.start);
91        let qspan = PositionSpan::range(query_run.start, end + 1);
92        let ispan = PositionSpan::range(0, rule_length);
93        let hispan = PositionSpan::from_positions(
94            (0..rule_length)
95                .filter(|&p| index.dictionary.token_kind(itokens[p]) == TokenKind::Legalese),
96        );
97
98        let license_match = LicenseMatch {
99            license_expression: rule.license_expression.clone(),
100            license_expression_spdx: index
101                .rule_metadata_by_identifier
102                .get(&rule.identifier)
103                .and_then(|metadata| metadata.license_expression_spdx.clone()),
104            from_file: None,
105            start_line,
106            end_line,
107            start_token: query_run.start,
108            end_token: query_run.end.map_or(query_run.start, |e| e + 1),
109            matcher: MATCH_HASH,
110            score: MatchScore::MAX,
111            matched_length,
112            rule_length,
113            match_coverage,
114            rule_relevance: rule.relevance,
115            rid: *rid,
116            rule_identifier: rule.identifier.clone(),
117            rule_url: rule.rule_url().unwrap_or_default(),
118            matched_text: None,
119            referenced_filenames: rule.referenced_filenames.clone(),
120            rule_kind: rule.kind(),
121            is_from_license: rule.is_from_license,
122            rule_start_token: 0,
123            coordinates: MatchCoordinates::rule_aligned(qspan, ispan, hispan),
124        };
125
126        matches.push(license_match);
127    }
128
129    matches
130}
131
132#[cfg(test)]
133#[path = "hash_match_test.rs"]
134mod tests;