use std::collections::HashMap;
use base64::Engine;
use chrono::{DateTime, FixedOffset};
use getset::{Getters, MutGetters, Setters};
use serde::{
Deserialize, Serialize,
de::{self, Visitor},
};
use uuid::Uuid;
use crate::prelude::{Error, EvidencePackage, Result as EVPResult};
const TESTCASE_SCHEMA_LOCATION: &str =
"https://evidenceangel-schemas.hpkns.uk/testcase.2.schema.json";
pub(crate) const TESTCASE_SCHEMA: &str = include_str!("../../schemas/testcase.2.schema.json");
#[derive(Clone, Debug, Serialize, Deserialize, Getters, MutGetters, Setters)]
pub struct TestCase {
#[serde(rename = "$schema")]
schema: Option<String>,
#[serde(skip)]
#[getset(get = "pub", set = "pub(super)")]
id: Uuid,
#[getset(get = "pub", get_mut = "pub")]
metadata: TestCaseMetadata,
#[getset(get = "pub", get_mut = "pub")]
evidence: Vec<Evidence>,
#[get = "pub"]
#[serde(flatten)]
extra_fields: HashMap<String, serde_json::Value>,
}
impl TestCase {
pub(super) fn new(id: Uuid, title: String, execution_datetime: DateTime<FixedOffset>) -> Self {
Self {
schema: Some(TESTCASE_SCHEMA_LOCATION.to_string()),
id,
metadata: TestCaseMetadata {
title,
execution_datetime,
passed: None,
custom: None,
extra_fields: HashMap::new(),
},
evidence: vec![],
extra_fields: HashMap::new(),
}
}
pub(super) fn update_schema(&mut self) {
self.schema = Some(TESTCASE_SCHEMA_LOCATION.to_string());
}
#[allow(
clippy::missing_panics_doc,
reason = "safety is statically checked by me"
)]
#[must_use]
pub fn attestation_payload(&self) -> String {
sha256::digest(serde_json_canonicalizer::to_string(&self).unwrap())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Getters, Setters)]
#[getset(get = "pub", set = "pub")]
pub struct TestCaseMetadata {
title: String,
execution_datetime: DateTime<FixedOffset>,
passed: Option<TestCasePassStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
custom: Option<HashMap<String, String>>,
#[get = "pub"]
#[serde(flatten)]
extra_fields: HashMap<String, serde_json::Value>,
}
impl TestCaseMetadata {
#[allow(clippy::missing_panics_doc, reason = "safety is explained inline")]
pub fn custom_mut(&mut self) -> &mut HashMap<String, String> {
if self.custom.is_none() {
self.custom = Some(HashMap::new());
}
self.custom.as_mut().unwrap()
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TestCasePassStatus {
#[serde(rename = "pass")]
Pass,
#[serde(rename = "fail")]
Fail,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Getters, MutGetters, Setters)]
#[getset(get = "pub")]
pub struct Evidence {
kind: String,
#[getset(get_mut = "pub", set = "pub")]
value: EvidenceData,
#[getset(get_mut = "pub", set = "pub")]
#[serde(skip_serializing_if = "Option::is_none")]
caption: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[getset(get_mut = "pub", set = "pub")]
original_filename: Option<String>,
#[get = "pub"]
#[serde(flatten)]
extra_fields: HashMap<String, serde_json::Value>,
}
impl Evidence {
#[must_use]
pub fn new<S: Into<String>>(kind: S, value: EvidenceData) -> Self {
Self {
kind: kind.into(),
value,
caption: None,
original_filename: None,
extra_fields: HashMap::new(),
}
}
pub fn data(&self, pkg: &mut super::EvidencePackage) -> Vec<u8> {
match self.value() {
EvidenceData::Text { content } => content.as_bytes().to_vec(),
EvidenceData::Base64 { data } => data.clone(),
EvidenceData::Media { hash } => {
tracing::debug!("Fetching media with hash {hash}");
let media = pkg.get_media(hash).ok().flatten();
tracing::debug!("Got media {media:?}");
media.unwrap().data().clone()
}
}
}
pub fn media_mime(&self, pkg: &mut super::EvidencePackage) -> Option<String> {
match self.value() {
EvidenceData::Media { hash } => {
tracing::debug!("Fetching media with hash {hash}");
pkg.media
.iter()
.find(|mfme| mfme.sha256_checksum() == hash)
.map(|mfme| mfme.mime_type().clone())
}
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EvidenceData {
Text {
content: String,
},
Base64 {
data: Vec<u8>,
},
Media {
hash: String,
},
}
impl EvidenceData {
pub fn get_data(&self, package: &mut EvidencePackage) -> EVPResult<Vec<u8>> {
match self {
Self::Text { content } => Ok(content.clone().into_bytes()),
Self::Base64 { data } => Ok(data.clone()),
Self::Media { hash } => package
.get_media(hash)?
.map(|mf| mf.data().clone())
.ok_or(Error::MediaMissing(hash.clone())),
}
}
}
struct EvidenceDataVisitor;
impl Visitor<'_> for EvidenceDataVisitor {
type Value = EvidenceData;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(r#"evidence data, in the format: "kind:value""#)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
Self::visit_string(self, v.to_string())
}
fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if let Some((typ, dat)) = v.split_once(':') {
return match typ {
"plain" => Ok(EvidenceData::Text {
content: dat.to_string(),
}),
"base64" => Ok(EvidenceData::Base64 {
data: base64::prelude::BASE64_STANDARD_NO_PAD
.decode(dat)
.map_err(serde::de::Error::custom)?,
}),
"media" => Ok(EvidenceData::Media {
hash: dat.to_string(),
}),
_ => Err(serde::de::Error::custom(format!(
"invalid type {typ}, expected one of plain, base64 or media"
))),
};
}
Err(de::Error::custom(
"invalid format, expected string with `:' separator",
))
}
}
impl Serialize for EvidenceData {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let data_s = match self {
Self::Text { content } => format!("plain:{content}"),
Self::Base64 { data } => format!(
"base64:{}",
base64::prelude::BASE64_STANDARD_NO_PAD.encode(data)
),
Self::Media { hash } => format!("media:{hash}"),
};
serializer.serialize_str(&data_s)
}
}
impl<'de> Deserialize<'de> for EvidenceData {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_string(EvidenceDataVisitor)
}
}