use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::{
error::DppError,
passport::{Passport, PassportId},
};
pub(crate) fn retention_deadline(now: DateTime<Utc>, years: u32) -> DateTime<Utc> {
now + chrono::Duration::days(365 * i64::from(years))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveReceipt {
pub archive_id: String,
pub passport_id: PassportId,
pub content_hash: String,
pub archived_at: DateTime<Utc>,
pub retention_until: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ArchiveStatus {
Active,
Updated,
RetentionLocked,
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArchiveVerification {
pub integrity_ok: bool,
pub accessible: bool,
pub status: ArchiveStatus,
pub last_verified_at: DateTime<Utc>,
}
#[async_trait]
pub trait ArchivePort: Send + Sync {
async fn archive(
&self,
passport: &Passport,
retention_years: u32,
) -> Result<ArchiveReceipt, DppError>;
async fn update_archive(&self, passport: &Passport) -> Result<ArchiveReceipt, DppError>;
async fn verify(
&self,
passport_id: PassportId,
expected_hash: &str,
) -> Result<ArchiveVerification, DppError>;
async fn retrieve(&self, passport_id: PassportId) -> Result<Option<Passport>, DppError>;
}
pub use crate::ports::ghosts::GhostArchive;
#[cfg(any(test, feature = "test-utils"))]
pub mod stub {
use super::*;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Mutex;
pub struct InMemoryArchive {
store: Mutex<HashMap<PassportId, (Passport, ArchiveReceipt)>>,
}
impl InMemoryArchive {
pub fn new() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
fn hash_passport(passport: &Passport) -> String {
let json = serde_json::to_vec(passport).unwrap_or_default();
let digest = Sha256::digest(&json);
hex::encode(digest)
}
}
impl Default for InMemoryArchive {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ArchivePort for InMemoryArchive {
async fn archive(
&self,
passport: &Passport,
retention_years: u32,
) -> Result<ArchiveReceipt, DppError> {
let now = Utc::now();
let retention_until = retention_deadline(now, retention_years);
let hash = Self::hash_passport(passport);
let receipt = ArchiveReceipt {
archive_id: format!("ARCHIVE-{}", uuid::Uuid::now_v7()),
passport_id: passport.id,
content_hash: hash,
archived_at: now,
retention_until,
};
let mut store = self.store.lock().unwrap();
store.insert(passport.id, (passport.clone(), receipt.clone()));
Ok(receipt)
}
async fn update_archive(&self, passport: &Passport) -> Result<ArchiveReceipt, DppError> {
let mut store = self.store.lock().unwrap();
if let Some((stored, receipt)) = store.get_mut(&passport.id) {
*stored = passport.clone();
receipt.content_hash = Self::hash_passport(passport);
Ok(receipt.clone())
} else {
Err(DppError::NotFound(format!(
"no archived record for {}",
passport.id
)))
}
}
async fn verify(
&self,
passport_id: PassportId,
expected_hash: &str,
) -> Result<ArchiveVerification, DppError> {
let store = self.store.lock().unwrap();
if let Some((_, receipt)) = store.get(&passport_id) {
Ok(ArchiveVerification {
integrity_ok: receipt.content_hash == expected_hash,
accessible: true,
status: ArchiveStatus::Active,
last_verified_at: Utc::now(),
})
} else {
Err(DppError::NotFound(format!(
"no archived record for {passport_id}"
)))
}
}
async fn retrieve(&self, passport_id: PassportId) -> Result<Option<Passport>, DppError> {
let store = self.store.lock().unwrap();
Ok(store.get(&passport_id).map(|(p, _)| p.clone()))
}
}
}
#[cfg(test)]
mod tests {
use super::stub::InMemoryArchive;
use super::*;
use crate::domain::passport::*;
use crate::domain::sector::{CarbonFootprint, RepairabilityScore};
use crate::domain::status::PassportStatus;
use chrono::Utc;
fn make_test_passport() -> Passport {
Passport {
product_name: "Test Textile".into(),
manufacturer: ManufacturerInfo {
name: "Test Brand".into(),
address: "Berlin, DE".into(),
did_web_url: Some("https://test.example.com/.well-known/did.json".into()),
},
co2e_per_unit: Some(CarbonFootprint::from_kg(3.5)),
repairability_score: Some(RepairabilityScore::from_scalar(7.0)),
status: PassportStatus::Published,
jws_signature: Some("eyJ0eXAiOiJKV1QifQ.test.signature".into()),
published_at: Some(Utc::now()),
retention_locked: true,
..crate::test_support::sample_passport()
}
}
#[tokio::test]
async fn archive_and_retrieve() {
let archive = InMemoryArchive::new();
let passport = make_test_passport();
let receipt = archive.archive(&passport, 10).await.unwrap();
assert!(!receipt.content_hash.is_empty());
assert!(receipt.archive_id.starts_with("ARCHIVE-"));
let retrieved = archive.retrieve(passport.id).await.unwrap();
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().id, passport.id);
}
#[tokio::test]
async fn verify_integrity_ok() {
let archive = InMemoryArchive::new();
let passport = make_test_passport();
let receipt = archive.archive(&passport, 10).await.unwrap();
let verification = archive
.verify(passport.id, &receipt.content_hash)
.await
.unwrap();
assert!(verification.integrity_ok);
assert!(verification.accessible);
assert_eq!(verification.status, ArchiveStatus::Active);
}
#[tokio::test]
async fn verify_integrity_mismatch() {
let archive = InMemoryArchive::new();
let passport = make_test_passport();
archive.archive(&passport, 10).await.unwrap();
let verification = archive.verify(passport.id, "bad-hash").await.unwrap();
assert!(!verification.integrity_ok);
}
#[tokio::test]
async fn update_archive_changes_hash() {
let archive = InMemoryArchive::new();
let mut passport = make_test_passport();
let receipt1 = archive.archive(&passport, 10).await.unwrap();
passport.product_name = "Updated Textile".into();
let receipt2 = archive.update_archive(&passport).await.unwrap();
assert_ne!(receipt1.content_hash, receipt2.content_hash);
}
#[tokio::test]
async fn retrieve_nonexistent_returns_none() {
let archive = InMemoryArchive::new();
let result = archive.retrieve(PassportId::new()).await.unwrap();
assert!(result.is_none());
}
}