use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;
use crate::{NotEvaluated, NotEvaluatedReason, ObjectId, Project, Report, Scope};
pub const NAMESPACE: Uuid = Uuid::from_u128(0x6b1f_5a0e_2c3d_4e8f_9a71_0d2c_5e4b_8f13);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FindingId(Uuid);
impl FindingId {
#[must_use]
pub fn uuid(self) -> Uuid {
self.0
}
}
impl From<Uuid> for FindingId {
fn from(uuid: Uuid) -> Self {
Self(uuid)
}
}
impl fmt::Display for FindingId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.hyphenated().fmt(f)
}
}
impl FromStr for FindingId {
type Err = IdentityError;
fn from_str(text: &str) -> Result<Self, IdentityError> {
Uuid::try_parse(text)
.map(Self)
.map_err(|_| IdentityError::NotAnIdentity(text.to_owned()))
}
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum IdentityError {
#[error("report names {0}, which is not in the project")]
UnknownObject(ObjectId),
#[error("`{0}` is not a finding identity (a UUID)")]
NotAnIdentity(String),
}
pub fn finding_ids(
report: &Report,
project: &Project,
stable_scheme: &str,
) -> Result<Vec<FindingId>, IdentityError> {
let keys = report
.findings()
.iter()
.map(|finding| {
let subject = finding.object_id();
let mut objects: Vec<&ObjectId> = subject.into_iter().collect();
objects.extend(&finding.related);
let resolved =
Resolved::new(&objects, &finding.scope, (report, project), stable_scheme)?;
let key = match &finding.scope {
Scope::Object(_) => format!(
"finding\n{}\n{}\n{}",
finding.rule_id, resolved.key, finding.message
),
Scope::Source(_) | Scope::Project => format!(
"finding\n{}\n{}\n{}\n{}",
finding.rule_id,
scope_marker(&finding.scope),
resolved.key,
finding.message
),
};
Ok((key, resolved.sources))
})
.collect::<Result<Vec<_>, IdentityError>>()?;
Ok(qualified(&keys).into_iter().map(FindingId).collect())
}
pub fn not_evaluated_ids(
report: &Report,
project: &Project,
stable_scheme: &str,
) -> Result<Vec<Uuid>, IdentityError> {
let keys = report
.not_evaluated()
.iter()
.map(|outcome: &NotEvaluated| {
let objects: Vec<&ObjectId> = outcome.object_id().into_iter().collect();
let resolved =
Resolved::new(&objects, &outcome.scope, (report, project), stable_scheme)?;
let key = format!(
"not-evaluated\n{}\n{}\n{}\n{}",
outcome.rule_id,
resolved.key,
reason(&outcome.reason),
outcome.message
);
Ok((key, resolved.sources))
})
.collect::<Result<Vec<_>, IdentityError>>()?;
Ok(qualified(&keys))
}
fn qualified(keys: &[(String, String)]) -> Vec<Uuid> {
let mut uses: BTreeMap<&str, usize> = BTreeMap::new();
for (key, _) in keys {
*uses.entry(key.as_str()).or_default() += 1;
}
keys.iter()
.map(|(key, sources)| {
let key = if uses[key.as_str()] > 1 {
format!("{key}\n{sources}")
} else {
key.clone()
};
Uuid::new_v5(&NAMESPACE, key.as_bytes())
})
.collect()
}
struct Resolved {
key: String,
sources: String,
}
impl Resolved {
fn new(
objects: &[&ObjectId],
scope: &Scope,
(report, project): (&Report, &Project),
stable_scheme: &str,
) -> Result<Self, IdentityError> {
let mut keys = Vec::with_capacity(objects.len());
let mut sources = BTreeSet::new();
sources.extend(scope.source().map(ToString::to_string));
for id in objects {
let object = report
.object(project, id)
.ok_or_else(|| IdentityError::UnknownObject((*id).clone()))?;
sources.insert(id.source.to_string());
keys.push(
object
.external_id(stable_scheme)
.map_or_else(|| id.to_string(), ToOwned::to_owned),
);
}
Ok(Self {
key: keys.join("\n"),
sources: sources.into_iter().collect::<Vec<_>>().join("\n"),
})
}
}
fn scope_marker(scope: &Scope) -> &'static str {
match scope {
Scope::Project => "project",
Scope::Source(_) => "source",
Scope::Object(_) => "object",
}
}
fn reason(reason: &NotEvaluatedReason) -> &'static str {
match reason {
NotEvaluatedReason::MissingService => "missing service",
NotEvaluatedReason::BackendUnavailable => "backend unavailable",
NotEvaluatedReason::IncompleteEvidence => "incomplete evidence",
NotEvaluatedReason::InvalidEvidence => "invalid evidence",
NotEvaluatedReason::InvalidDeclaration => "invalid declaration",
NotEvaluatedReason::UnboundConcept => "unbound concept",
NotEvaluatedReason::NotRecorded => "not recorded",
NotEvaluatedReason::ResourceLimit => "resource limit",
}
}