1use crate::{
5 DIDWebVHError, Version,
6 log_entry::{spec_1_0::LogEntry1_0, spec_1_0_pre::LogEntry1_0Pre},
7 parameters::Parameters,
8 witness::Witnesses,
9};
10use affinidi_data_integrity::{DataIntegrityProof, VerifyOptions};
11use base58::ToBase58;
12use chrono::{DateTime, FixedOffset};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use serde_json_canonicalizer::to_string;
16use sha2::{Digest, Sha256};
17use std::{fs::OpenOptions, io::Write};
18use tracing::debug;
19
20pub mod read;
21pub mod spec_1_0;
22pub mod spec_1_0_pre;
23
24pub(crate) fn encode_sha256_multihash(digest: &[u8]) -> Vec<u8> {
28 let mut buf = Vec::with_capacity(2 + digest.len());
29 buf.push(0x12); buf.push(0x20); buf.extend_from_slice(digest);
32 buf
33}
34
35#[derive(Clone, Debug, Default, Deserialize, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct MetaData {
40 pub version_id: String,
42 pub version_number: u32,
46 pub version_time: String,
48 pub created: String,
50 pub updated: String,
52 pub scid: String,
54 pub portable: bool,
56 pub deactivated: bool,
58 pub witness: Option<Witnesses>,
60 pub watchers: Option<Vec<String>>,
62}
63
64pub trait PublicKey {
66 fn get_public_key_bytes(&self) -> Result<Vec<u8>, DIDWebVHError>;
68}
69
70pub(crate) fn enforce_witness_proof_shape(
79 proof: &DataIntegrityProof,
80 options: &crate::witness::WitnessVerifyOptions,
81) -> Result<(), DIDWebVHError> {
82 if !options.suite_is_allowed(proof.cryptosuite) {
83 return Err(DIDWebVHError::WitnessProofError(format!(
84 "witness proof uses cryptosuite {:?}, but the didwebvh 1.0 spec \
85 requires eddsa-jcs-2022 (add to WitnessVerifyOptions::extra_allowed_suites \
86 to accept non-spec suites explicitly)",
87 proof.cryptosuite
88 )));
89 }
90 if proof.proof_purpose != "assertionMethod" {
91 return Err(DIDWebVHError::WitnessProofError(format!(
92 "witness proof has proofPurpose '{}', but the didwebvh 1.0 spec \
93 requires 'assertionMethod'",
94 proof.proof_purpose
95 )));
96 }
97 Ok(())
98}
99
100impl PublicKey for DataIntegrityProof {
101 fn get_public_key_bytes(&self) -> Result<Vec<u8>, DIDWebVHError> {
102 Ok(
109 affinidi_data_integrity::did_vm::resolve_did_key(&self.verification_method)
110 .map_err(|e| {
111 DIDWebVHError::InvalidMethodIdentifier(format!(
112 "could not resolve did:key verificationMethod: {e}"
113 ))
114 })?
115 .public_key_bytes,
116 )
117 }
118}
119
120#[non_exhaustive]
123#[derive(Clone, Debug, Deserialize, Serialize)]
124#[serde(untagged)]
125pub enum LogEntry {
126 Spec1_0(LogEntry1_0),
128
129 Spec1_0Pre(LogEntry1_0Pre),
131}
132
133pub trait LogEntryMethods {
135 fn get_version_time_string(&self) -> String;
137
138 fn get_version_time(&self) -> DateTime<FixedOffset>;
140
141 fn get_version_id(&self) -> &str;
143
144 fn set_version_id(&mut self, version_id: &str);
146
147 fn get_parameters(&self) -> Parameters;
149
150 fn add_proof(&mut self, proof: DataIntegrityProof);
152
153 fn get_proofs(&self) -> &[DataIntegrityProof];
155
156 fn clear_proofs(&mut self);
158
159 fn get_scid(&self) -> Option<&str>;
161
162 fn get_state(&self) -> &Value;
165
166 fn get_did_document(&self) -> Result<Value, DIDWebVHError>;
186}
187
188pub(crate) trait LogEntryCreate {
190 fn create(
191 version_id: String,
192 version_time: DateTime<FixedOffset>,
193 parameters: Parameters,
194 state: Value,
195 ) -> Result<LogEntry, DIDWebVHError>;
196}
197
198pub(crate) fn format_version_time<S>(
200 date: &DateTime<FixedOffset>,
201 serializer: S,
202) -> Result<S::Ok, S::Error>
203where
204 S: serde::Serializer,
205{
206 serializer.serialize_str(&date.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
207}
208
209pub fn parse_version_id_fields(version_id: &str) -> Result<(u32, String), DIDWebVHError> {
211 let Some((id, hash)) = version_id.split_once('-') else {
212 return Err(DIDWebVHError::ValidationError(format!(
213 "versionID ({version_id}) doesn't match format <int>-<hash>",
214 )));
215 };
216 let id = id.parse::<u32>().map_err(|e| {
217 DIDWebVHError::ValidationError(format!("Failed to parse version ID ({id}) as u32: {e}",))
218 })?;
219 Ok((id, hash.to_string()))
220}
221
222macro_rules! impl_log_entry_common {
227 ($type:ty) => {
228 impl $type {
229 pub fn generate_log_entry_hash(&self) -> Result<String, DIDWebVHError> {
231 let jcs = serde_json_canonicalizer::to_string(self).map_err(|e| {
232 DIDWebVHError::SCIDError(format!(
233 "Couldn't generate JCS from LogEntry. Reason: {e}",
234 ))
235 })?;
236 tracing::debug!("JCS for LogEntry hash: {}", jcs);
237
238 let digest = <sha2::Sha256 as sha2::Digest>::digest(jcs.as_bytes());
239 let multihash_bytes = crate::log_entry::encode_sha256_multihash(digest.as_slice());
240 Ok(base58::ToBase58::to_base58(multihash_bytes.as_slice()))
241 }
242
243 pub fn validate_witness_proof(
245 &self,
246 witness_proof: &affinidi_data_integrity::DataIntegrityProof,
247 options: &crate::witness::WitnessVerifyOptions,
248 ) -> Result<bool, DIDWebVHError> {
249 use crate::log_entry::PublicKey;
250 crate::log_entry::enforce_witness_proof_shape(witness_proof, options)?;
251 witness_proof
252 .verify_with_public_key(
253 &serde_json::json!({"versionId": &self.version_id}),
254 witness_proof.get_public_key_bytes()?.as_slice(),
255 affinidi_data_integrity::VerifyOptions::new(),
256 )
257 .map_err(|e| {
258 DIDWebVHError::LogEntryError(format!(
259 "Data Integrity Proof verification failed: {e}"
260 ))
261 })?;
262 Ok(true)
263 }
264
265 pub fn get_version_id_fields(&self) -> Result<(u32, String), DIDWebVHError> {
267 crate::log_entry::parse_version_id_fields(&self.version_id)
268 }
269
270 pub fn parse_version_id_fields(
272 version_id: &str,
273 ) -> Result<(u32, String), DIDWebVHError> {
274 crate::log_entry::parse_version_id_fields(version_id)
275 }
276 }
277
278 impl crate::log_entry::LogEntryMethods for $type {
279 fn get_version_time_string(&self) -> String {
280 self.version_time
281 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
282 }
283
284 fn get_version_time(&self) -> chrono::DateTime<chrono::FixedOffset> {
285 self.version_time
286 }
287
288 fn get_version_id(&self) -> &str {
289 &self.version_id
290 }
291
292 fn set_version_id(&mut self, version_id: &str) {
293 self.version_id = version_id.to_string();
294 }
295
296 fn get_parameters(&self) -> crate::parameters::Parameters {
297 self.parameters.clone().into()
298 }
299
300 fn add_proof(&mut self, proof: affinidi_data_integrity::DataIntegrityProof) {
301 self.proof.push(proof);
302 }
303
304 fn get_proofs(&self) -> &[affinidi_data_integrity::DataIntegrityProof] {
305 &self.proof
306 }
307
308 fn clear_proofs(&mut self) {
309 self.proof.clear();
310 }
311
312 fn get_scid(&self) -> Option<&str> {
313 self.parameters.scid.as_deref().map(String::as_str)
314 }
315
316 fn get_state(&self) -> &serde_json::Value {
317 &self.state
318 }
319
320 fn get_did_document(&self) -> Result<serde_json::Value, DIDWebVHError> {
321 let services = self.state.get("service");
322 let mut new_state = self.state.clone();
323 if let Some(id) = self.state.get("id")
324 && let Some(id) = id.as_str()
325 {
326 crate::resolve::implicit::update_implicit_services(
327 services, &mut new_state, id,
328 )?;
329 Ok(new_state)
330 } else {
331 Err(DIDWebVHError::ValidationError(
332 "DID Document is missing 'id' field or it's not a string".to_string(),
333 ))
334 }
335 }
336 }
337 };
338}
339
340pub(crate) use impl_log_entry_common;
341
342impl LogEntry {
343 pub fn deserialize_string(
350 input: &str,
351 version: Option<Version>,
352 ) -> Result<LogEntry, DIDWebVHError> {
353 let values: Value = serde_json::from_str(input).map_err(|e| {
355 DIDWebVHError::LogEntryError(format!("Couldn't deserialize LogEntry. Reason: {e}"))
356 })?;
357
358 let version = if let Some(parameters) = values.get("parameters") {
360 if let Some(method) = parameters.get("method") {
361 if let Some(method) = method.as_str() {
362 Version::try_from(method).unwrap_or(version.unwrap_or_default())
363 } else {
364 version.unwrap_or_default()
365 }
366 } else {
367 version.unwrap_or_default()
368 }
369 } else {
370 version.unwrap_or_default()
371 };
372
373 match version {
376 Version::V1_0 => {
377 let Some(parameters) = values.get("parameters") else {
380 return Err(DIDWebVHError::LogEntryError(
381 "No parameters exist in the LogEntry!".to_string(),
382 ));
383 };
384
385 let mut pre_version = false;
387 if let Some(v) = parameters.get("updateKeys")
388 && v.is_null()
389 {
390 pre_version = true;
391 }
392 if let Some(v) = parameters.get("nextKeyHashes")
393 && v.is_null()
394 {
395 pre_version = true;
396 }
397 if let Some(v) = parameters.get("witness")
398 && v.is_null()
399 {
400 pre_version = true;
401 }
402 if let Some(v) = parameters.get("watchers")
403 && v.is_null()
404 {
405 pre_version = true;
406 }
407 if let Some(v) = parameters.get("ttl")
408 && v.is_null()
409 {
410 pre_version = true;
411 }
412
413 if pre_version {
414 Ok(LogEntry::Spec1_0Pre(
415 serde_json::from_value::<LogEntry1_0Pre>(values).map_err(|e| {
416 DIDWebVHError::LogEntryError(format!("Failed to parse LogEntry: {e}"))
417 })?,
418 ))
419 } else {
420 Ok(LogEntry::Spec1_0(
421 serde_json::from_value::<LogEntry1_0>(values).map_err(|e| {
422 DIDWebVHError::LogEntryError(format!("Failed to parse LogEntry: {e}"))
423 })?,
424 ))
425 }
426 }
427 _ => Err(DIDWebVHError::LogEntryError(format!(
428 "Version ({version}) is not supported!"
429 ))),
430 }
431 }
432
433 pub fn get_webvh_version(&self) -> Version {
435 match self {
436 LogEntry::Spec1_0(_) => Version::V1_0,
437 LogEntry::Spec1_0Pre(_) => Version::V1_0Pre,
438 }
439 }
440
441 pub fn from_string_to_known_version(
443 input: &str,
444 version: Version,
445 ) -> Result<LogEntry, DIDWebVHError> {
446 match version {
447 Version::V1_0 => serde_json::from_str::<LogEntry1_0>(input)
448 .map(LogEntry::Spec1_0)
449 .map_err(|e| {
450 DIDWebVHError::LogEntryError(format!("Failed to parse LogEntry: {e}"))
451 }),
452 Version::V1_0Pre => serde_json::from_str::<LogEntry1_0Pre>(input)
453 .map(LogEntry::Spec1_0Pre)
454 .map_err(|e| {
455 DIDWebVHError::LogEntryError(format!("Failed to parse LogEntry: {e}"))
456 }),
457 }
458 }
459
460 pub fn save_to_file(&self, file_path: &str) -> Result<(), DIDWebVHError> {
462 let append = if self.get_version_id_fields()?.0 == 1 {
463 false } else {
465 true };
467
468 let mut file = OpenOptions::new()
469 .create(true)
470 .write(true)
471 .truncate(!append)
472 .append(append)
473 .open(file_path)
474 .map_err(|e| {
475 DIDWebVHError::LogEntryError(format!("Couldn't open file {file_path}: {e}"))
476 })?;
477
478 file.write_all(
479 serde_json::to_string(self)
480 .map_err(|e| {
481 DIDWebVHError::LogEntryError(format!(
482 "Couldn't serialize LogEntry to JSON. Reason: {e}",
483 ))
484 })?
485 .as_bytes(),
486 )
487 .map_err(|e| {
488 DIDWebVHError::LogEntryError(format!(
489 "Couldn't append LogEntry to file({file_path}). Reason: {e}",
490 ))
491 })?;
492 file.write_all("\n".as_bytes()).map_err(|e| {
493 DIDWebVHError::LogEntryError(format!(
494 "Couldn't append LogEntry to file({file_path}). Reason: {e}",
495 ))
496 })?;
497
498 Ok(())
499 }
500
501 pub(crate) fn generate_first_scid(&self) -> Result<String, DIDWebVHError> {
504 self.generate_log_entry_hash().map_err(|e| {
505 DIDWebVHError::SCIDError(format!(
506 "Couldn't generate SCID from preliminary LogEntry. Reason: {e}",
507 ))
508 })
509 }
510
511 pub fn generate_log_entry_hash(&self) -> Result<String, DIDWebVHError> {
513 let jcs = to_string(self).map_err(|e| {
514 DIDWebVHError::SCIDError(format!("Couldn't generate JCS from LogEntry. Reason: {e}",))
515 })?;
516 debug!("JCS for LogEntry hash: {}", jcs);
517
518 let digest = Sha256::digest(jcs.as_bytes());
519 let multihash_bytes = encode_sha256_multihash(digest.as_slice());
520 Ok(multihash_bytes.to_base58())
521 }
522
523 pub fn validate_witness_proof(
525 &self,
526 witness_proof: &DataIntegrityProof,
527 options: &crate::witness::WitnessVerifyOptions,
528 ) -> Result<bool, DIDWebVHError> {
529 enforce_witness_proof_shape(witness_proof, options)?;
530 witness_proof
532 .verify_with_public_key(
533 &json!({"versionId": self.get_version_id()}),
534 witness_proof.get_public_key_bytes()?.as_slice(),
535 VerifyOptions::new(),
536 )
537 .map_err(|e| {
538 DIDWebVHError::LogEntryError(format!(
539 "Data Integrity Proof verification failed: {e}"
540 ))
541 })?;
542
543 Ok(true)
544 }
545
546 pub fn get_version_id_fields(&self) -> Result<(u32, String), DIDWebVHError> {
548 match self {
549 LogEntry::Spec1_0(log_entry) => parse_version_id_fields(&log_entry.version_id),
550 LogEntry::Spec1_0Pre(log_entry) => parse_version_id_fields(&log_entry.version_id),
551 }
552 }
553
554 pub fn parse_version_id_fields(version_id: &str) -> Result<(u32, String), DIDWebVHError> {
556 parse_version_id_fields(version_id)
557 }
558
559 pub(crate) fn create(
561 version_id: String,
562 version_time: DateTime<FixedOffset>,
563 parameters: Parameters,
564 state: Value,
565 webvh_version: Version,
566 ) -> Result<LogEntry, DIDWebVHError> {
567 match webvh_version {
568 Version::V1_0 => LogEntry1_0::create(version_id, version_time, parameters, state),
569 Version::V1_0Pre => Err(DIDWebVHError::LogEntryError(
570 "WebVH Version must be 1.0 or higher".to_string(),
571 )),
572 }
573 }
574}
575
576impl LogEntryMethods for LogEntry {
577 fn get_version_time_string(&self) -> String {
578 match self {
579 LogEntry::Spec1_0(log_entry) => log_entry.get_version_time_string(),
580 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_version_time_string(),
581 }
582 }
583
584 fn get_version_id(&self) -> &str {
585 match self {
586 LogEntry::Spec1_0(log_entry) => log_entry.get_version_id(),
587 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_version_id(),
588 }
589 }
590
591 fn set_version_id(&mut self, version_id: &str) {
592 match self {
593 LogEntry::Spec1_0(log_entry) => {
594 log_entry.set_version_id(version_id);
595 }
596 LogEntry::Spec1_0Pre(log_entry) => {
597 log_entry.set_version_id(version_id);
598 }
599 }
600 }
601
602 fn get_parameters(&self) -> Parameters {
603 match self {
604 LogEntry::Spec1_0(log_entry) => log_entry.get_parameters(),
605 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_parameters(),
606 }
607 }
608
609 fn add_proof(&mut self, proof: DataIntegrityProof) {
610 match self {
611 LogEntry::Spec1_0(log_entry) => log_entry.add_proof(proof),
612 LogEntry::Spec1_0Pre(log_entry) => log_entry.add_proof(proof),
613 }
614 }
615
616 fn get_proofs(&self) -> &[DataIntegrityProof] {
617 match self {
618 LogEntry::Spec1_0(log_entry) => log_entry.get_proofs(),
619 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_proofs(),
620 }
621 }
622
623 fn clear_proofs(&mut self) {
624 match self {
625 LogEntry::Spec1_0(log_entry) => log_entry.clear_proofs(),
626 LogEntry::Spec1_0Pre(log_entry) => log_entry.clear_proofs(),
627 }
628 }
629
630 fn get_scid(&self) -> Option<&str> {
631 match self {
632 LogEntry::Spec1_0(log_entry) => log_entry.get_scid(),
633 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_scid(),
634 }
635 }
636
637 fn get_version_time(&self) -> DateTime<FixedOffset> {
638 match self {
639 LogEntry::Spec1_0(log_entry) => log_entry.get_version_time(),
640 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_version_time(),
641 }
642 }
643
644 fn get_state(&self) -> &Value {
645 match self {
646 LogEntry::Spec1_0(log_entry) => log_entry.get_state(),
647 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_state(),
648 }
649 }
650
651 fn get_did_document(&self) -> Result<Value, DIDWebVHError> {
652 match self {
653 LogEntry::Spec1_0(log_entry) => log_entry.get_did_document(),
654 LogEntry::Spec1_0Pre(log_entry) => log_entry.get_did_document(),
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use crate::{Version, parameters::spec_1_0::Parameters1_0};
663 use affinidi_data_integrity::{DataIntegrityProof, crypto_suites::CryptoSuite};
664 use chrono::Utc;
665 use serde_json::json;
666
667 #[test]
673 fn metadata_serialises_version_number_as_camel_case_integer() {
674 let meta = MetaData {
675 version_id: "42-QmExample".to_string(),
676 version_number: 42,
677 version_time: "2000-01-01T00:00:00Z".to_string(),
678 created: "2000-01-01T00:00:00Z".to_string(),
679 updated: "2000-01-01T00:00:00Z".to_string(),
680 scid: "QmExample".to_string(),
681 portable: false,
682 deactivated: false,
683 witness: None,
684 watchers: None,
685 };
686 let v = serde_json::to_value(&meta).unwrap();
687 assert_eq!(v.get("versionNumber"), Some(&serde_json::json!(42)));
688 assert_eq!(v.get("versionId"), Some(&serde_json::json!("42-QmExample")));
689 let round_tripped: MetaData = serde_json::from_value(v).unwrap();
690 assert_eq!(round_tripped.version_number, 42);
691 }
692
693 #[test]
701 fn test_public_key_not_did_key_error() {
702 let proof = DataIntegrityProof::new(
703 CryptoSuite::EddsaJcs2022,
704 "did:web:example.com#key-1".to_string(), "test".to_string(), None, None, None, );
710 let err = proof.get_public_key_bytes().unwrap_err();
711 assert!(err.to_string().contains("did:key:"));
712 }
713
714 #[test]
720 fn test_public_key_missing_hash_error() {
721 let proof = DataIntegrityProof::new(
722 CryptoSuite::EddsaJcs2022,
723 "did:key:z6MktestNoHash".to_string(), "test".to_string(), None, None, None, );
729 let err = proof.get_public_key_bytes().unwrap_err();
730 assert!(
733 matches!(err, DIDWebVHError::InvalidMethodIdentifier(_)),
734 "expected InvalidMethodIdentifier, got {err:?}"
735 );
736 }
737
738 #[test]
744 fn test_public_key_valid() {
745 let secret = affinidi_secrets_resolver::secrets::Secret::generate_ed25519(None, None);
747 let pk = secret.get_public_keymultibase().unwrap();
748 let proof = DataIntegrityProof::new(
749 CryptoSuite::EddsaJcs2022,
750 format!("did:key:{pk}#{pk}"), "assertionMethod".to_string(), None, None, None, );
756 let bytes = proof.get_public_key_bytes().unwrap();
757 assert!(!bytes.is_empty());
758 }
759
760 #[test]
767 fn test_deserialize_invalid_json_error() {
768 let result = LogEntry::deserialize_string("not json", None);
769 assert!(result.is_err());
770 }
771
772 #[test]
778 fn test_deserialize_missing_parameters_error() {
779 let json = r#"{"versionId":"1-abc","versionTime":"2024-01-01T00:00:00Z","state":{}}"#;
781 let result = LogEntry::deserialize_string(json, None);
782 assert!(result.is_err());
783 }
784
785 #[test]
791 fn test_deserialize_v1_0_ok() {
792 let json = serde_json::to_string(&LogEntry::Spec1_0(LogEntry1_0 {
793 version_id: "1-abc".to_string(),
794 version_time: Utc::now().fixed_offset(),
795 parameters: Parameters1_0::default(),
796 state: json!({"id": "did:webvh:scid:example.com"}),
797 proof: vec![],
798 }))
799 .unwrap();
800 let result = LogEntry::deserialize_string(&json, None).unwrap();
801 assert!(matches!(result, LogEntry::Spec1_0(_)));
802 }
803
804 #[test]
811 fn test_deserialize_detects_pre_version_nulls() {
812 let json = r#"{"versionId":"1-abc","versionTime":"2024-01-01T00:00:00Z","parameters":{"method":"did:webvh:1.0","scid":"test","updateKeys":null},"state":{}}"#;
814 let result = LogEntry::deserialize_string(json, None).unwrap();
815 assert!(matches!(result, LogEntry::Spec1_0Pre(_)));
816 }
817
818 #[test]
827 fn test_from_string_v1_0() {
828 let json = serde_json::to_string(&LogEntry::Spec1_0(LogEntry1_0 {
829 version_id: "1-abc".to_string(),
830 version_time: Utc::now().fixed_offset(),
831 parameters: Parameters1_0::default(),
832 state: json!({}),
833 proof: vec![],
834 }))
835 .unwrap();
836 let result = LogEntry::from_string_to_known_version(&json, Version::V1_0).unwrap();
837 assert!(matches!(result, LogEntry::Spec1_0(_)));
838 }
839
840 #[test]
848 fn test_from_string_v1_0_pre() {
849 let json = r#"{"versionId":"1-abc","versionTime":"2024-01-01T00:00:00Z","parameters":{"method":"did:webvh:1.0","scid":"test","updateKeys":null,"nextKeyHashes":null,"witness":null,"watchers":null,"deactivated":null,"ttl":null},"state":{}}"#;
851 let result = LogEntry::from_string_to_known_version(json, Version::V1_0Pre).unwrap();
852 assert!(matches!(result, LogEntry::Spec1_0Pre(_)));
853 }
854
855 #[test]
864 fn test_get_did_document_with_services() {
865 let entry = LogEntry::Spec1_0(LogEntry1_0 {
866 version_id: "1-abc".to_string(),
867 version_time: Utc::now().fixed_offset(),
868 parameters: Parameters1_0::default(),
869 state: json!({
870 "id": "did:webvh:scid123:example.com",
871 "service": [{
872 "id": "did:webvh:scid123:example.com#custom",
873 "type": "Custom",
874 "serviceEndpoint": "https://example.com"
875 }]
876 }),
877 proof: vec![],
878 });
879 let doc = entry.get_did_document().unwrap();
880 let services = doc["service"].as_array().unwrap();
881 assert_eq!(services.len(), 3);
883 }
884
885 #[test]
891 fn test_get_did_document_missing_id_error() {
892 let entry = LogEntry::Spec1_0(LogEntry1_0 {
893 version_id: "1-abc".to_string(),
894 version_time: Utc::now().fixed_offset(),
895 parameters: Parameters1_0::default(),
896 state: json!({"noId": true}),
897 proof: vec![],
898 });
899 let err = entry.get_did_document().unwrap_err();
900 assert!(err.to_string().contains("id"));
901 }
902
903 #[test]
911 fn test_save_and_load_roundtrip() {
912 let entry = LogEntry::Spec1_0(LogEntry1_0 {
913 version_id: "1-abc".to_string(),
914 version_time: Utc::now().fixed_offset(),
915 parameters: Parameters1_0::default(),
916 state: json!({"id": "did:webvh:scid:example.com"}),
917 proof: vec![],
918 });
919 let unique_name = format!(
920 "didwebvh_test_save_roundtrip_{}.jsonl",
921 std::time::SystemTime::now()
922 .duration_since(std::time::UNIX_EPOCH)
923 .unwrap()
924 .as_nanos()
925 );
926 let path = std::env::temp_dir().join(unique_name);
927 let path_str = path.to_str().unwrap();
928 entry.save_to_file(path_str).unwrap();
929 let loaded = LogEntry::load_from_file(path_str).unwrap();
930 assert_eq!(loaded.len(), 1);
931 assert_eq!(loaded[0].get_version_id(), "1-abc");
932 let _ = std::fs::remove_file(path);
933 }
934
935 #[test]
937 fn test_generate_hash_deterministic() {
938 let entry = LogEntry::Spec1_0(LogEntry1_0 {
939 version_id: "1-abc".to_string(),
940 version_time: Utc::now().fixed_offset(),
941 parameters: Parameters1_0::default(),
942 state: json!({"id": "did:webvh:scid:example.com"}),
943 proof: vec![],
944 });
945 let hash1 = entry.generate_log_entry_hash().unwrap();
946 let hash2 = entry.generate_log_entry_hash().unwrap();
947 assert_eq!(hash1, hash2);
948 }
949
950 #[test]
972 fn test_jcs_sorts_service_object_keys() {
973 let state_a: serde_json::Value = serde_json::from_str(
975 r##"{
976 "id": "did:webvh:scid:example.com",
977 "service": [
978 {"id": "#a", "type": "X", "serviceEndpoint": "https://a"}
979 ]
980 }"##,
981 )
982 .unwrap();
983 let state_b: serde_json::Value = serde_json::from_str(
984 r##"{
985 "service": [
986 {"serviceEndpoint": "https://a", "type": "X", "id": "#a"}
987 ],
988 "id": "did:webvh:scid:example.com"
989 }"##,
990 )
991 .unwrap();
992
993 let now = Utc::now().fixed_offset();
994 let mk = |state| {
995 LogEntry::Spec1_0(LogEntry1_0 {
996 version_id: "1-abc".to_string(),
997 version_time: now,
998 parameters: Parameters1_0::default(),
999 state,
1000 proof: vec![],
1001 })
1002 };
1003
1004 assert_eq!(
1005 mk(state_a).generate_log_entry_hash().unwrap(),
1006 mk(state_b).generate_log_entry_hash().unwrap(),
1007 "JCS must sort object keys — key insertion order must not affect hash",
1008 );
1009 }
1010
1011 #[test]
1016 fn test_jcs_preserves_service_array_order() {
1017 let svc_a = json!({"id": "#a", "type": "X", "serviceEndpoint": "https://a"});
1018 let svc_b = json!({"id": "#b", "type": "Y", "serviceEndpoint": "https://b"});
1019
1020 let now = Utc::now().fixed_offset();
1021 let mk = |services: Vec<serde_json::Value>| {
1022 LogEntry::Spec1_0(LogEntry1_0 {
1023 version_id: "1-abc".to_string(),
1024 version_time: now,
1025 parameters: Parameters1_0::default(),
1026 state: json!({
1027 "id": "did:webvh:scid:example.com",
1028 "service": services,
1029 }),
1030 proof: vec![],
1031 })
1032 };
1033
1034 let hash_ab = mk(vec![svc_a.clone(), svc_b.clone()])
1035 .generate_log_entry_hash()
1036 .unwrap();
1037 let hash_ba = mk(vec![svc_b, svc_a]).generate_log_entry_hash().unwrap();
1038
1039 assert_ne!(
1040 hash_ab, hash_ba,
1041 "JCS preserves array order — swapping services must change the hash",
1042 );
1043 }
1044
1045 #[test]
1050 fn test_jcs_preserves_authentication_array_order() {
1051 let now = Utc::now().fixed_offset();
1052 let mk = |refs: Vec<&str>| {
1053 LogEntry::Spec1_0(LogEntry1_0 {
1054 version_id: "1-abc".to_string(),
1055 version_time: now,
1056 parameters: Parameters1_0::default(),
1057 state: json!({
1058 "id": "did:webvh:scid:example.com",
1059 "authentication": refs,
1060 }),
1061 proof: vec![],
1062 })
1063 };
1064
1065 let hash_ab = mk(vec!["#k1", "#k2"]).generate_log_entry_hash().unwrap();
1066 let hash_ba = mk(vec!["#k2", "#k1"]).generate_log_entry_hash().unwrap();
1067
1068 assert_ne!(hash_ab, hash_ba);
1069 }
1070
1071 #[test]
1079 fn test_get_did_document_does_not_affect_hash() {
1080 let entry = LogEntry::Spec1_0(LogEntry1_0 {
1081 version_id: "1-abc".to_string(),
1082 version_time: Utc::now().fixed_offset(),
1083 parameters: Parameters1_0::default(),
1084 state: json!({
1085 "id": "did:webvh:scid123:example.com",
1086 "service": [
1087 {"id": "#custom", "type": "Custom", "serviceEndpoint": "https://example.com"}
1088 ]
1089 }),
1090 proof: vec![],
1091 });
1092
1093 let hash_before = entry.generate_log_entry_hash().unwrap();
1094 let state_before = entry.get_state().clone();
1095
1096 let resolved = entry.get_did_document().unwrap();
1098 let resolved_services = resolved["service"].as_array().unwrap();
1099 assert_eq!(
1100 resolved_services.len(),
1101 3,
1102 "resolution adds #files + #whois"
1103 );
1104
1105 assert_eq!(entry.get_state(), &state_before, "state must not mutate");
1107 assert_eq!(entry.get_state()["service"].as_array().unwrap().len(), 1);
1108 assert_eq!(
1109 entry.generate_log_entry_hash().unwrap(),
1110 hash_before,
1111 "hash must not change after get_did_document()",
1112 );
1113 }
1114
1115 #[test]
1123 fn test_create_v1_0_pre_error() {
1124 let result = LogEntry::create(
1125 "1-test".to_string(),
1126 Utc::now().fixed_offset(),
1127 Parameters::default(),
1128 json!({}),
1129 Version::V1_0Pre,
1130 );
1131 assert!(result.is_err());
1132 assert!(result.unwrap_err().to_string().contains("1.0 or higher"));
1133 }
1134
1135 #[test]
1143 fn test_parse_version_id_valid() {
1144 let (num, hash) = parse_version_id_fields("3-abc123").unwrap();
1145 assert_eq!(num, 3);
1146 assert_eq!(hash, "abc123");
1147 }
1148
1149 #[test]
1154 fn test_parse_version_id_missing_dash() {
1155 let err = parse_version_id_fields("noDash").unwrap_err();
1156 assert!(err.to_string().contains("versionID"));
1157 }
1158
1159 #[test]
1165 fn test_parse_version_id_non_numeric() {
1166 let err = parse_version_id_fields("abc-hash").unwrap_err();
1167 assert!(err.to_string().contains("Failed to parse version ID"));
1168 }
1169
1170 #[test]
1178 fn test_get_webvh_version() {
1179 let entry_1_0 = LogEntry::Spec1_0(LogEntry1_0 {
1180 version_id: "1-abc".to_string(),
1181 version_time: Utc::now().fixed_offset(),
1182 parameters: Parameters1_0::default(),
1183 state: json!({}),
1184 proof: vec![],
1185 });
1186 assert_eq!(entry_1_0.get_webvh_version(), Version::V1_0);
1187 }
1188}