use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::error::CalcError;
pub use super::hashing::{input_hash, jcs_hash};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalculationReceipt {
pub receipt_id: Uuid,
pub input_hash: String,
pub output_hash: String,
pub ruleset_id: String,
pub ruleset_version: String,
pub factor_dataset_id: String,
pub factor_dataset_version: String,
pub factor_set_hash: Option<String>,
pub computed_at: DateTime<Utc>,
pub jws: Option<String>,
}
impl CalculationReceipt {
pub fn new(
input_hash: impl Into<String>,
ruleset_id: impl Into<String>,
ruleset_version: impl Into<String>,
) -> Self {
Self {
receipt_id: Uuid::now_v7(),
input_hash: input_hash.into(),
output_hash: String::new(),
ruleset_id: ruleset_id.into(),
ruleset_version: ruleset_version.into(),
factor_dataset_id: String::new(),
factor_dataset_version: String::new(),
factor_set_hash: None,
computed_at: Utc::now(),
jws: None,
}
}
pub fn with_output_hash(mut self, hash: impl Into<String>) -> Self {
self.output_hash = hash.into();
self
}
pub fn with_factor_provider(mut self, provider: &dyn super::factor::FactorProvider) -> Self {
self.factor_dataset_id = provider.dataset_id().to_owned();
self.factor_dataset_version = provider.dataset_version().to_owned();
self.factor_set_hash = Some(provider.table_hash().to_owned());
self
}
pub fn seal_with_jws(mut self, jws: String) -> Self {
self.jws = Some(jws);
self
}
pub fn canonical_bytes_for_signing(&self) -> Result<Vec<u8>, CalcError> {
let mut v =
serde_json::to_value(self).map_err(|e| CalcError::CanonicalizeError(e.to_string()))?;
if let Some(obj) = v.as_object_mut() {
obj.remove("jws");
}
serde_jcs::to_vec(&v).map_err(|e| CalcError::CanonicalizeError(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::factor::FactorProvider;
struct DummyProvider;
impl FactorProvider for DummyProvider {
fn dataset_id(&self) -> &str {
"dummy-ds"
}
fn dataset_version(&self) -> &str {
"1.2.3"
}
fn gwp100(&self, _activity_uuid: &str) -> Result<f64, CalcError> {
Ok(1.0)
}
fn table_hash(&self) -> &str {
"deadbeef"
}
}
#[test]
fn builder_records_output_factor_and_jws() {
let receipt = CalculationReceipt::new("in-hash", "ruleset-x", "1.0.0")
.with_output_hash("out-hash")
.with_factor_provider(&DummyProvider)
.seal_with_jws("jws-token".to_owned());
assert_eq!(receipt.input_hash, "in-hash");
assert_eq!(receipt.output_hash, "out-hash");
assert_eq!(receipt.ruleset_id, "ruleset-x");
assert_eq!(receipt.ruleset_version, "1.0.0");
assert_eq!(receipt.factor_dataset_id, "dummy-ds");
assert_eq!(receipt.factor_dataset_version, "1.2.3");
assert_eq!(receipt.factor_set_hash.as_deref(), Some("deadbeef"));
assert_eq!(receipt.jws.as_deref(), Some("jws-token"));
}
#[test]
fn canonical_bytes_exclude_the_jws_field() {
let sealed = CalculationReceipt::new("in", "r", "1.0.0").seal_with_jws("secret".to_owned());
let bytes = sealed.canonical_bytes_for_signing().unwrap();
let text = String::from_utf8(bytes).unwrap();
assert!(
!text.contains("secret"),
"jws must be excluded from the signing payload"
);
assert!(text.contains("in"));
}
}