provenant-cli 1.0.10

Fast Rust scanner for licenses, copyrights, package metadata, SBOMs, and provenance data.
// SPDX-FileCopyrightText: nexB Inc. and others
// ScanCode is a trademark of nexB Inc.
// SPDX-FileCopyrightText: Provenant contributors
// SPDX-License-Identifier: Apache-2.0
// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.

use regex::Regex;
use std::sync::LazyLock;

use crate::models::LineNumber;

use super::host::is_good_email_domain;
use super::junk_data::classify_email;
use super::{DetectionConfig, decode_pod_angle_escapes};

#[derive(Debug, Clone, PartialEq)]
pub struct EmailDetection {
    pub email: String,
    pub start_line: LineNumber,
    pub end_line: LineNumber,
}

static EMAILS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?i)\b[A-Z0-9._%-]+@[A-Z0-9.-]+\.[A-Z]{2,63}\b").expect("valid email regex")
});

pub fn find_emails(text: &str, config: &DetectionConfig) -> Vec<EmailDetection> {
    let mut detections = Vec::new();

    for (line_index, line) in text.lines().enumerate() {
        let line_number = LineNumber::from_0_indexed(line_index);
        let normalized_line = line.replace("\\r\\n", "\\n").replace("\\r", "\\n");
        for segment in normalized_line.split("\\n") {
            let segment = decode_pod_angle_escapes(segment);
            let segment = segment.as_ref();
            for matched in EMAILS_REGEX.find_iter(segment) {
                // Skip SSH remotes such as `git@github.com:org/repo.git` and
                // `user@host:port` URL authorities. Only a `:` followed by a
                // path segment (contains `/`) or a bare numeric port qualifies —
                // so structured text like `owner=admin@corp.com:active` keeps its
                // email.
                if let Some(after_colon) = segment[matched.end()..].strip_prefix(':') {
                    let suffix = after_colon.split_whitespace().next().unwrap_or("");
                    let is_ssh_path = suffix.contains('/');
                    let is_port = !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit());
                    if is_ssh_path || is_port {
                        continue;
                    }
                }
                // RFC 5321 makes the local part case-sensitive, so emit the source form.
                let email = matched.as_str();
                if !is_good_email_domain(email) {
                    continue;
                }
                if !classify_email(email) {
                    continue;
                }

                detections.push(EmailDetection {
                    email: email.to_string(),
                    start_line: line_number,
                    end_line: line_number,
                });
            }
        }
    }

    // Address identity ignores case even though the emitted value keeps it.
    let mut detections = if config.unique {
        let mut seen = std::collections::HashSet::<String>::new();
        detections
            .into_iter()
            .filter(|d| seen.insert(d.email.to_lowercase()))
            .collect::<Vec<_>>()
    } else {
        detections
    };

    if config.max_emails > 0 && detections.len() > config.max_emails {
        let mut seen = std::collections::HashSet::<String>::new();
        detections.retain(|d| seen.insert(d.email.to_lowercase()));
        detections.truncate(config.max_emails);
    }

    detections
}

#[cfg(test)]
mod tests {
    use super::*;

    fn emails(text: &str) -> Vec<String> {
        find_emails(text, &DetectionConfig::default())
            .into_iter()
            .map(|d| d.email)
            .collect()
    }

    #[test]
    fn test_find_emails_preserves_source_case() {
        assert_eq!(
            emails("mailto Richard.M.Bartel@ccMail.Census.GOV now"),
            vec!["Richard.M.Bartel@ccMail.Census.GOV"]
        );
        assert_eq!(
            emails("send to Paul.Green@stratus.com"),
            vec!["Paul.Green@stratus.com"]
        );
    }

    #[test]
    fn test_find_emails_dedupes_case_variants_of_one_address() {
        let config = DetectionConfig {
            unique: true,
            ..DetectionConfig::default()
        };
        let detections = find_emails(
            "Paul.Green@stratus.com\npaul.green@stratus.com\nPAUL.GREEN@STRATUS.COM\n",
            &config,
        );
        // First occurrence wins, so the emitted value stays the one the source shows first.
        assert_eq!(
            detections
                .iter()
                .map(|d| d.email.as_str())
                .collect::<Vec<_>>(),
            vec!["Paul.Green@stratus.com"]
        );
    }

    #[test]
    fn test_find_emails_caps_on_case_insensitive_uniqueness() {
        let config = DetectionConfig {
            max_emails: 2,
            max_urls: 0,
            unique: false,
        };
        let detections = find_emails("a@corp.com\nA@Corp.com\nb@corp.com\nc@corp.com\n", &config);
        assert_eq!(
            detections
                .iter()
                .map(|d| d.email.as_str())
                .collect::<Vec<_>>(),
            vec!["a@corp.com", "b@corp.com"]
        );
    }

    #[test]
    fn test_find_emails_skips_ssh_remote_and_authority() {
        // `git@github.com:org/repo.git` SSH remote (colon + path) is not an email.
        assert!(emails("clone: git clone git@github.com:tonsky/FiraCode.git").is_empty());
        // `user@host:port` URL authority (colon + numeric port) is not an email.
        assert!(emails("connect admin@dbhost.io:5432 now").is_empty());
        // A real email followed by a colon + non-path text is still captured.
        assert_eq!(
            emails("owner=admin@corp.com:active"),
            vec!["admin@corp.com"]
        );
        // A real email followed by a colon and a space is still captured.
        assert_eq!(
            emails("Contact real@corp.com: for help"),
            vec!["real@corp.com"]
        );
        // Plain email untouched.
        assert_eq!(
            emails("mail jane@realcorp.io today"),
            vec!["jane@realcorp.io"]
        );
    }

    #[test]
    fn test_find_emails_decodes_perl_pod_angle_escapes() {
        assert_eq!(
            emails("Peter John Acklam E<lt>pjacklam@gmail.comE<gt>"),
            vec!["pjacklam@gmail.com"]
        );
    }
}