use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::time::Duration;
use bytes::Bytes;
use reqwest::Method;
use serde_json::{Map, Value, json};
use sha2::{Digest, Sha256};
use skald_spec::MediaFormat;
use wyrd_spec::auth::Sha256Hex;
use wyrd_spec::card::verifier::{InputSlot, VerifierSpec};
use wyrd_spec::error::WyrdError;
use wyrd_spec::ids::{CardUid, GroupId, SlotName};
use wyrd_spec::reference::CardRef;
use wyrd_spec::vala::ids::{RunId, TraceId};
use wyrd_spec::verification::EXECUTION_DEADLINE;
use wyrd_spec::verification::execution::{
EnqueueVerificationResponse, EvidenceFile, EvidencePlanRequest, EvidencePlanResponse,
InputDeclaration, VerificationRequest, VerificationResult,
};
use crate::client::WyrdClient;
mod execution;
use crate::storage::upload::sha256_file;
use crate::storage::{FileSource, WyrdStorageClient};
pub use execution::Execution;
const EXECUTE_RESPONSE_GRACE: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, PartialEq)]
pub struct File {
bytes: FileBytes,
format: Option<MediaFormat>,
filename: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
enum FileBytes {
Path(PathBuf),
Memory(Bytes),
}
impl File {
#[must_use]
pub fn path(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let filename = path
.file_name()
.map(|name| name.to_string_lossy().into_owned());
Self {
bytes: FileBytes::Path(path),
format: None,
filename,
}
}
#[must_use]
pub fn bytes(bytes: impl Into<Bytes>) -> Self {
Self {
bytes: FileBytes::Memory(bytes.into()),
format: None,
filename: None,
}
}
#[must_use]
pub fn with_format(mut self, format: MediaFormat) -> Self {
self.format = Some(format);
self
}
#[must_use]
pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
self.filename = Some(filename.into());
self
}
async fn pin(&self, slot: &SlotName) -> Result<(EvidenceFile, Upload), WyrdError> {
let (size_bytes, digest) = match &self.bytes {
FileBytes::Path(path) => sha256_file(path).await.map_err(|error| {
input_invalid(
"a verification file cannot be read",
json!({ "slot": slot, "path": path, "source": error.to_string() }),
)
})?,
FileBytes::Memory(bytes) => (bytes.len() as u64, Sha256::digest(bytes).into()),
};
let sha256 = Sha256Hex::from(digest);
let upload = match &self.bytes {
FileBytes::Path(path) => Upload::Path(path.clone()),
FileBytes::Memory(bytes) => Upload::Memory(bytes.clone()),
};
Ok((EvidenceFile { sha256, size_bytes }, upload))
}
fn format(&self, accepted: &[MediaFormat]) -> Option<MediaFormat> {
self.format
.or_else(|| {
let name = self.filename.as_deref()?;
name.rsplit_once('.')?.1.parse().ok()
})
.or(match accepted {
[only] => Some(*only),
_ => None,
})
}
}
#[derive(Debug, Clone)]
enum Upload {
Path(PathBuf),
Memory(Bytes),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactRef {
artifact: CardRef,
path: Option<String>,
}
impl ArtifactRef {
#[must_use]
pub fn new(artifact: CardRef, path: Option<String>) -> Self {
Self { artifact, path }
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Input {
Value(Value),
File(File),
Artifact(ArtifactRef),
}
impl From<Value> for Input {
fn from(value: Value) -> Self {
Self::Value(value)
}
}
impl From<File> for Input {
fn from(file: File) -> Self {
Self::File(file)
}
}
impl From<ArtifactRef> for Input {
fn from(artifact: ArtifactRef) -> Self {
Self::Artifact(artifact)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Inputs {
One(Input),
Named(BTreeMap<String, Input>),
}
impl Inputs {
#[must_use]
pub fn named<K: Into<String>, V: Into<Input>>(
values: impl IntoIterator<Item = (K, V)>,
) -> Self {
Self::Named(
values
.into_iter()
.map(|(name, value)| (name.into(), value.into()))
.collect(),
)
}
}
impl<T: Into<Input>> From<T> for Inputs {
fn from(value: T) -> Self {
Self::One(value.into())
}
}
#[derive(Debug, Clone)]
pub enum Submission {
Recorded,
Queued(Execution),
}
#[derive(Debug)]
pub(crate) struct Target<'a> {
pub(crate) spec: &'a VerifierSpec,
pub(crate) verifier_card_uid: CardUid,
pub(crate) card_uid: CardUid,
pub(crate) run_id: RunId,
pub(crate) group_id: Option<GroupId>,
}
#[derive(Debug)]
pub(crate) enum Projected {
Window(Value),
Run(RunEvidence),
}
#[derive(Debug, Default)]
pub(crate) struct RunEvidence {
declarations: BTreeMap<SlotName, InputDeclaration>,
files: Vec<(SlotName, File, Vec<MediaFormat>, Option<u64>)>,
}
impl Target<'_> {
pub(crate) fn project(&self, inputs: Inputs) -> Result<Projected, WyrdError> {
let named = match inputs {
Inputs::Named(named) => named,
Inputs::One(value) => {
let mut slots = self.spec.inputs.keys();
let (Some(slot), None) = (slots.next(), slots.next()) else {
return Err(input_invalid(
"a positional value needs a Verifier with exactly one input slot",
json!({ "slots": self.spec.inputs.keys().collect::<Vec<_>>() }),
));
};
BTreeMap::from([(slot.as_str().to_owned(), value)])
}
};
let window = self
.spec
.inputs
.values()
.any(|slot| matches!(slot, InputSlot::Samples { .. }));
let mut features = Map::new();
let mut run = RunEvidence::default();
for (name, value) in named {
let (slot, kind) = self
.spec
.inputs
.iter()
.find(|(slot, _)| slot.as_str() == name)
.ok_or_else(|| {
input_invalid(
"the Verifier declares no input slot with this name",
json!({ "slot": name, "slots": self.spec.inputs.keys().collect::<Vec<_>>() }),
)
})?;
match (kind, value) {
(InputSlot::Json { .. }, Input::Value(value)) => {
run.declarations
.insert(slot.clone(), InputDeclaration::Json { value });
}
(InputSlot::Trace { .. }, Input::Value(Value::String(text))) => {
let trace_id = text.parse::<TraceId>().map_err(|_| {
input_invalid(
"a trace slot takes a 32-hex-digit trace ID",
json!({ "slot": slot }),
)
})?;
run.declarations
.insert(slot.clone(), InputDeclaration::Trace { trace_id });
}
(InputSlot::Media { .. }, Input::Artifact(ArtifactRef { artifact, path })) => {
run.declarations
.insert(slot.clone(), InputDeclaration::Artifact { artifact, path });
}
(
InputSlot::Media {
formats, max_bytes, ..
},
Input::File(file),
) => run
.files
.push((slot.clone(), file, formats.clone(), *max_bytes)),
(
InputSlot::Samples {
features: names, ..
},
Input::Value(value),
) => {
samples(slot, names, value, &mut features)?;
}
(kind, value) => {
return Err(input_invalid(
"the value does not fit its input slot",
json!({ "slot": slot, "slot_type": slot_type(kind), "value": input_kind(&value) }),
));
}
}
}
Ok(if window {
Projected::Window(Value::Object(features))
} else {
Projected::Run(run)
})
}
pub(crate) async fn execute(
&self,
client: &WyrdClient,
run: RunEvidence,
) -> Result<VerificationResult, WyrdError> {
let request = self.request(run.pin(client).await?);
client
.with_min_request_timeout(EXECUTION_DEADLINE + EXECUTE_RESPONSE_GRACE)
.http
.request_json(Method::POST, "/v1/verification/execute", Some(&request))
.await
}
pub(crate) async fn enqueue(
&self,
client: &WyrdClient,
run: RunEvidence,
) -> Result<Execution, WyrdError> {
let request = self.request(run.pin(client).await?);
let key = format!("verification-submit-{}", uuid::Uuid::now_v7());
let response: EnqueueVerificationResponse = client
.http
.submit_with_idempotency_key(Method::POST, "/v1/verification/enqueue", &request, &key)
.await?;
Ok(Execution::new(response.execution_id, client.clone()))
}
fn request(&self, inputs: BTreeMap<SlotName, InputDeclaration>) -> VerificationRequest {
VerificationRequest {
verifier_card_uid: self.verifier_card_uid.clone(),
card_uid: self.card_uid.clone(),
run_id: Some(self.run_id.clone()),
group_id: self.group_id.clone(),
inputs,
execution_route_overrides: BTreeMap::new(),
}
}
}
impl RunEvidence {
async fn pin(
mut self,
client: &WyrdClient,
) -> Result<BTreeMap<SlotName, InputDeclaration>, WyrdError> {
if self.files.is_empty() {
return Ok(self.declarations);
}
let mut sources = HashMap::new();
let mut files = Vec::new();
for (slot, file, formats, max_bytes) in self.files {
let format = file.format(&formats).ok_or_else(|| {
input_invalid(
"declare the file's format; it has no recognised extension",
json!({ "slot": slot, "accepted": formats }),
)
})?;
if !formats.contains(&format) {
return Err(input_invalid(
"the file's format is not accepted by its slot",
json!({ "slot": slot, "format": format, "accepted": formats }),
));
}
let (evidence, upload) = file.pin(&slot).await?;
if max_bytes.is_some_and(|max| evidence.size_bytes > max) {
return Err(WyrdError::VerificationInputTooLarge {
message: "the file exceeds its slot's max_bytes".to_owned(),
details: json!({ "slot": slot, "size_bytes": evidence.size_bytes, "max_bytes": max_bytes }),
});
}
self.declarations.insert(
slot,
InputDeclaration::File {
sha256: evidence.sha256,
size_bytes: evidence.size_bytes,
format,
filename: file.filename,
},
);
if sources.insert(evidence.sha256, upload).is_none() {
files.push(evidence);
}
}
let plan = EvidencePlanRequest { files };
let response: EvidencePlanResponse = client
.http
.request_json(Method::POST, "/v1/verification/evidence", Some(&plan))
.await?;
let storage = WyrdStorageClient::new(client);
for upload in response.uploads {
let Some(source) = sources.get(&upload.sha256) else {
continue;
};
let key = format!("verification-evidence-{}", upload.sha256);
match source {
Upload::Path(path) => {
let source = FileSource::open(path).await?;
storage.upload_evidence(&upload.plan, source, &key).await?;
}
Upload::Memory(bytes) => {
storage
.upload_evidence(&upload.plan, bytes.clone(), &key)
.await?;
}
}
}
Ok(self.declarations)
}
}
fn samples(
slot: &SlotName,
names: &[wyrd_spec::ids::FeatureName],
value: Value,
features: &mut Map<String, Value>,
) -> Result<(), WyrdError> {
let object = match (value, names) {
(Value::Object(object), _) => object,
(value, [only]) => Map::from_iter([(only.as_str().to_owned(), value)]),
(value, _) => {
return Err(input_invalid(
"a samples slot takes an object of its declared measurements",
json!({ "slot": slot, "value": json_kind(&value) }),
));
}
};
for (name, value) in object {
if !names.iter().any(|declared| declared.as_str() == name) {
return Err(input_invalid(
"the samples slot does not declare this measurement",
json!({ "slot": slot, "measurement": name, "declared": names }),
));
}
features.insert(name, value);
}
Ok(())
}
fn slot_type(slot: &InputSlot) -> &'static str {
match slot {
InputSlot::Json { .. } => "json",
InputSlot::Media { .. } => "media",
InputSlot::Trace { .. } => "trace",
InputSlot::Samples { .. } => "samples",
InputSlot::Source(_) => "source",
}
}
fn json_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn input_kind(input: &Input) -> &'static str {
match input {
Input::Value(value) => json_kind(value),
Input::File(_) => "file",
Input::Artifact(_) => "artifact",
}
}
pub(crate) fn input_invalid(message: &str, details: Value) -> WyrdError {
WyrdError::VerificationInputInvalid {
message: message.to_owned(),
details,
}
}