Skip to main content

didwebvh_rs/log_entry/
mod.rs

1/*!
2*   Webvh utilizes Log Entries for each version change of the DID Document.
3*/
4use 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
24/// Encodes a SHA-256 digest as a multihash byte array.
25/// Multihash format: [hash_function_code, digest_length, ...digest_bytes]
26/// SHA-256 code = 0x12, digest length = 0x20 (32 bytes)
27pub(crate) fn encode_sha256_multihash(digest: &[u8]) -> Vec<u8> {
28    let mut buf = Vec::with_capacity(2 + digest.len());
29    buf.push(0x12); // SHA-256 hash function code
30    buf.push(0x20); // 32 bytes digest length
31    buf.extend_from_slice(digest);
32    buf
33}
34
35/// Resolved Document MetaData
36/// Returned as resolved Document MetaData on a successful resolve
37#[derive(Clone, Debug, Default, Deserialize, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct MetaData {
40    /// The `<version_number>-<hash>` identifier for this log entry.
41    pub version_id: String,
42    /// Integer version number parsed from `version_id` (e.g. `2` for
43    /// `"2-Qm..."`). Exposed as a sibling of `version_id` for consumers
44    /// that want the integer without parsing the string.
45    pub version_number: u32,
46    /// RFC 3339 timestamp when this version was created.
47    pub version_time: String,
48    /// RFC 3339 timestamp when the DID was first created.
49    pub created: String,
50    /// RFC 3339 timestamp of the most recent update.
51    pub updated: String,
52    /// Self-Certifying Identifier (SCID) for the DID.
53    pub scid: String,
54    /// Whether the DID is portable (can change its web address).
55    pub portable: bool,
56    /// Whether the DID has been deactivated.
57    pub deactivated: bool,
58    /// Active witness configuration, if any.
59    pub witness: Option<Witnesses>,
60    /// Watcher endpoints configured for this DID.
61    pub watchers: Option<Vec<String>>,
62}
63
64/// Extracts raw public key bytes from a data integrity proof.
65pub trait PublicKey {
66    /// Decode the verification method into raw public key bytes.
67    fn get_public_key_bytes(&self) -> Result<Vec<u8>, DIDWebVHError>;
68}
69
70/// Enforces the didwebvh 1.0 shape constraints on a witness proof before
71/// cryptographic verification: cryptosuite must be `eddsa-jcs-2022` (unless
72/// the caller widened the allowed set via [`WitnessVerifyOptions`]), and
73/// `proofPurpose` must be `assertionMethod`. Returns an error mentioning
74/// the exact violation — the spec is unambiguous here, so silent acceptance
75/// would hide real interop bugs.
76///
77/// [`WitnessVerifyOptions`]: crate::witness::WitnessVerifyOptions
78pub(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        // Delegate to the upstream resolver: it knows every multicodec
103        // registered for public keys (Ed25519, secp256k1, P-256/384/521,
104        // and — with feature gates — the ML-DSA / SLH-DSA variants) and
105        // validates the decoded length. Centralising the logic upstream
106        // means new key types added in affinidi-data-integrity flow
107        // through here without a didwebvh-rs patch.
108        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/// Each version of the DID gets a new log entry
121/// [Log Entries](https://identity.foundation/didwebvh/v1.0/#the-did-log-file)
122#[non_exhaustive]
123#[derive(Clone, Debug, Deserialize, Serialize)]
124#[serde(untagged)]
125pub enum LogEntry {
126    /// Official v1.0 specification
127    Spec1_0(LogEntry1_0),
128
129    /// Interim 1.0 spec where nulls were used instead of empty arrays and objects
130    Spec1_0Pre(LogEntry1_0Pre),
131}
132
133/// Common accessors shared by all log entry versions.
134pub trait LogEntryMethods {
135    /// LogEntry Parameters versionTime
136    fn get_version_time_string(&self) -> String;
137
138    /// LogEntry Parameters versionTime
139    fn get_version_time(&self) -> DateTime<FixedOffset>;
140
141    /// Returns the versionId for this log entry.
142    fn get_version_id(&self) -> &str;
143
144    /// Set the versionId to an updated value.
145    fn set_version_id(&mut self, version_id: &str);
146
147    /// Get Parameters
148    fn get_parameters(&self) -> Parameters;
149
150    /// Add a proof for this log entry.
151    fn add_proof(&mut self, proof: DataIntegrityProof);
152
153    /// Get proofs for this log entry.
154    fn get_proofs(&self) -> &[DataIntegrityProof];
155
156    /// Resets all proofs for this LogEntry
157    fn clear_proofs(&mut self);
158
159    /// Returns the SCID if present in this log entry's parameters.
160    fn get_scid(&self) -> Option<&str>;
161
162    /// Get the raw DID Document state
163    /// Does NOT include implied services
164    fn get_state(&self) -> &Value;
165
166    /// Returns a full DID Document including implied services
167    /// (`#files` / `#whois`).
168    ///
169    /// This builds a fresh `Value` by cloning `state` and appending any
170    /// missing implicit services — the LogEntry itself is **not** mutated.
171    /// The returned document is for **resolution / display only**.
172    ///
173    /// # ⚠ DO NOT feed this back into [`create_log_entry`] or [`update`]
174    ///
175    /// The hash chain (`versionId`, SCID, `eddsa-jcs-2022` proofs) is
176    /// computed over the LogEntry's stored `state`, which by spec excludes
177    /// the implicit services. Passing the augmented document from
178    /// `get_did_document()` as the new `state` would bake `#files` and
179    /// `#whois` into the canonical bytes, breaking interop with every other
180    /// didwebvh implementation. If you need the document for an update, use
181    /// [`get_state`](Self::get_state) instead.
182    ///
183    /// [`create_log_entry`]: crate::DIDWebVHState::create_log_entry
184    /// [`update`]: crate::update::update_did
185    fn get_did_document(&self) -> Result<Value, DIDWebVHError>;
186}
187
188/// Where-ever we need to create a LogEntry across versions
189pub(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
198/// Shared helper: serialize versionTime with seconds-only precision
199pub(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
209/// Shared helper: split a versionId into (number, hash)
210pub 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
222/// Implements the common inherent methods and `LogEntryMethods` trait for a log entry struct.
223///
224/// The struct must have fields: `version_id`, `version_time`, `parameters`, `state`, `proof`.
225/// The parameters type must implement `Into<Parameters>` and `Clone`.
226macro_rules! impl_log_entry_common {
227    ($type:ty) => {
228        impl $type {
229            /// Calculates a Log Entry hash
230            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            /// Verifies a witness data integrity proof against this log entry's versionId.
244            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            /// Splits the version number and the version hash for a DID versionId
266            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            /// Splits the version number and the version hash for a DID versionId
271            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    /// Reading in a LogEntry and converting it requires custom logic.
344    /// `deserialize_string` handles detecting the version and deserializing the LogEntry correctly
345    /// Attributes:
346    /// - input: The input string to deserialize
347    /// - version: If you want to override the default latest version, specify the previous
348    ///   LogEntry version here
349    pub fn deserialize_string(
350        input: &str,
351        version: Option<Version>,
352    ) -> Result<LogEntry, DIDWebVHError> {
353        // Step 1: Parse the String to generic JSON Values
354        let values: Value = serde_json::from_str(input).map_err(|e| {
355            DIDWebVHError::LogEntryError(format!("Couldn't deserialize LogEntry. Reason: {e}"))
356        })?;
357
358        // Step 2: Detect method version
359        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        // Step 3: Deserialize using the LogEntry method version
374
375        match version {
376            Version::V1_0 => {
377                // There is a pre-ratified difference in the v1.0 spec where nulls were used
378                // instead of empty arrays and objects
379                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                // Check if there are JSON nulls in the parameters
386                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    /// Get the WebVH Specification version for this LogEntry
434    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    /// Converts a string into the correct version when version is known
442    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    /// Append a valid LogEntry to a file
461    pub fn save_to_file(&self, file_path: &str) -> Result<(), DIDWebVHError> {
462        let append = if self.get_version_id_fields()?.0 == 1 {
463            false // Don't append to the file if this is the first version
464        } else {
465            true // Append to the file for all subsequent versions
466        };
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    /// Generates a SCID from a preliminary LogEntry
502    /// This only needs to be called once when the DID is first created.
503    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    /// Calculates a Log Entry hash
512    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    /// Validates a witness proof against the log entry
524    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        // Verify the Data Integrity Proof against the Signing Document
531        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    /// Splits the version number and the version hash for a DID versionId
547    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    /// Splits the version number and the version hash for a DID versionId
555    pub fn parse_version_id_fields(version_id: &str) -> Result<(u32, String), DIDWebVHError> {
556        parse_version_id_fields(version_id)
557    }
558
559    /// Create a new LogEntry depending on the WebVH Version
560    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    // ===== MetaData tests =====
668
669    /// `MetaData::version_number` serialises as `versionNumber` alongside
670    /// `versionId`. Required for TS-interop parity with didwebvh-ts resolver
671    /// output.
672    #[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    // ===== PublicKey trait tests =====
694
695    /// Tests that extracting a public key from a proof whose verification method
696    /// does not start with "did:key:" returns an error.
697    /// Expected: Returns an error mentioning "did:key:".
698    /// This matters because WebVH log entry proofs must use did:key verification
699    /// methods; rejecting other DID methods prevents invalid key extraction.
700    #[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(), // verification_method
705            "test".to_string(),                      // proof_purpose
706            None,                                    // proof_value
707            None,                                    // created
708            None,                                    // context
709        );
710        let err = proof.get_public_key_bytes().unwrap_err();
711        assert!(err.to_string().contains("did:key:"));
712    }
713
714    /// Tests that a did:key verification method missing the fragment separator (#)
715    /// returns an "Invalid verification method" error.
716    /// Expected: Returns an error indicating the verification method format is invalid.
717    /// This matters because the public key is extracted from the fragment portion
718    /// after the '#'; without it, key extraction cannot proceed safely.
719    #[test]
720    fn test_public_key_missing_hash_error() {
721        let proof = DataIntegrityProof::new(
722            CryptoSuite::EddsaJcs2022,
723            "did:key:z6MktestNoHash".to_string(), // verification_method
724            "test".to_string(),                   // proof_purpose
725            None,                                 // proof_value
726            None,                                 // created
727            None,                                 // context
728        );
729        let err = proof.get_public_key_bytes().unwrap_err();
730        // resolve_did_key fails to decode the multibase body because
731        // "z6MktestNoHash" is not a valid base58btc multicodec payload.
732        assert!(
733            matches!(err, DIDWebVHError::InvalidMethodIdentifier(_)),
734            "expected InvalidMethodIdentifier, got {err:?}"
735        );
736    }
737
738    /// Tests that a well-formed did:key verification method with a valid ed25519
739    /// multikey successfully extracts non-empty public key bytes.
740    /// Expected: Returns a non-empty byte vector representing the public key.
741    /// This matters because valid key extraction is the prerequisite for
742    /// verifying data integrity proofs on log entries.
743    #[test]
744    fn test_public_key_valid() {
745        // Use a real ed25519 multikey
746        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}"),  // verification_method
751            "assertionMethod".to_string(), // proof_purpose
752            None,                          // proof_value
753            None,                          // created
754            None,                          // context
755        );
756        let bytes = proof.get_public_key_bytes().unwrap();
757        assert!(!bytes.is_empty());
758    }
759
760    // ===== deserialize_string() tests =====
761
762    /// Tests that deserializing a non-JSON string returns an error.
763    /// Expected: Returns a deserialization error.
764    /// This matters because log entries are transmitted as JSON; malformed input
765    /// must be caught early to prevent downstream processing of invalid data.
766    #[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    /// Tests that valid JSON lacking a "parameters" field fails deserialization
773    /// for the V1_0 spec variant.
774    /// Expected: Returns an error because the parameters block is required.
775    /// This matters because every WebVH log entry must carry parameters (method,
776    /// scid, updateKeys, etc.) to be a valid entry in the DID log.
777    #[test]
778    fn test_deserialize_missing_parameters_error() {
779        // Valid JSON but no parameters field — for V1_0, it checks for parameters
780        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    /// Tests that a properly serialized Spec1_0 log entry round-trips through
786    /// deserialize_string and is recognized as the Spec1_0 variant.
787    /// Expected: Returns Ok with a LogEntry::Spec1_0 variant.
788    /// This matters because correct version detection ensures log entries are
789    /// parsed with the right schema, maintaining interoperability.
790    #[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    /// Tests that JSON with null-valued parameter fields (e.g. "updateKeys": null)
805    /// is detected as the pre-ratification Spec1_0Pre variant rather than Spec1_0.
806    /// Expected: Returns Ok with a LogEntry::Spec1_0Pre variant.
807    /// This matters because the pre-ratified spec used JSON nulls instead of empty
808    /// arrays/objects; distinguishing the two ensures backward compatibility with
809    /// DIDs created before the spec was finalized.
810    #[test]
811    fn test_deserialize_detects_pre_version_nulls() {
812        // JSON with null values in parameters should trigger Spec1_0Pre
813        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    // ===== from_string_to_known_version() tests =====
819
820    /// Tests that from_string_to_known_version correctly parses a string as
821    /// the Spec1_0 variant when Version::V1_0 is explicitly specified.
822    /// Expected: Returns Ok with a LogEntry::Spec1_0 variant.
823    /// This matters because when the version is already known (e.g. from a
824    /// previous log entry), skipping auto-detection is more efficient and avoids
825    /// ambiguity.
826    #[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    /// Tests that from_string_to_known_version correctly parses a string with
841    /// null-valued parameters as the Spec1_0Pre variant when Version::V1_0Pre
842    /// is explicitly specified.
843    /// Expected: Returns Ok with a LogEntry::Spec1_0Pre variant.
844    /// This matters because pre-ratified log entries use a different serialization
845    /// format (nulls vs empty collections) and must be parsed with the correct
846    /// deserializer to avoid data loss.
847    #[test]
848    fn test_from_string_v1_0_pre() {
849        // Spec1_0Pre uses null serialization, construct manually
850        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    // ===== get_did_document() tests =====
856
857    /// Tests that get_did_document adds implicit services (whois, files) alongside
858    /// any explicit services defined in the DID document state.
859    /// Expected: The returned document contains 3 services (1 custom + 2 implicit).
860    /// This matters because the WebVH spec requires certain implied services to be
861    /// present in the resolved DID document even when they are not stored in the
862    /// log entry state.
863    #[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        // Should have original + whois + files = 3
882        assert_eq!(services.len(), 3);
883    }
884
885    /// Tests that get_did_document returns an error when the DID document state
886    /// is missing the required "id" field.
887    /// Expected: Returns an error referencing the missing "id".
888    /// This matters because the DID document id is essential for constructing
889    /// implied service endpoints; without it, the document cannot be resolved.
890    #[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    // ===== save_to_file() and generate_log_entry_hash() tests =====
904
905    /// Tests that a log entry can be saved to a JSONL file and loaded back with
906    /// identical content, verifying the file I/O round-trip.
907    /// Expected: The loaded entry has the same versionId and exactly one entry.
908    /// This matters because the DID log file is the persistent representation of
909    /// all version history; data must survive serialization without corruption.
910    #[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    /// Repeated hash calls on the same value are stable — trivial idempotence.
936    #[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    // ===== JCS canonicalisation guarantees =====
951    //
952    // These tests pin down the two halves of RFC 8785's canonical form on the
953    // surface that matters most for didwebvh — the `service` block in
954    // `state`:
955    //
956    //   1. Object-key ORDER is normalised (sorted) — different key
957    //      insertion orders MUST produce the same hash.
958    //   2. Array-element ORDER is preserved — different service ordering
959    //      MUST produce different hashes.
960    //
961    // If either of these regresses (e.g. someone swaps the canonicalizer
962    // crate or accidentally pre-sorts arrays), the entry hash and SCID will
963    // silently diverge from every other didwebvh implementation. Catching
964    // that here is much cheaper than catching it via a failed interop test.
965
966    /// JCS sorts object keys: two service blocks whose object-keys are in
967    /// different insertion order MUST hash identically. Inputs are built as
968    /// raw JSON strings (not via `json!`) because `serde_json::Value` already
969    /// pre-sorts via its `BTreeMap` backing — going through string parsing
970    /// keeps the test honest about what the canonicalizer is doing.
971    #[test]
972    fn test_jcs_sorts_service_object_keys() {
973        // r##"..."## (double-#) so the JSON `"#a"` doesn't terminate the raw string.
974        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    /// JCS preserves array order: swapping two services in the `service`
1012    /// array MUST change the hash. RFC 8785 §3.2.4 says array order is
1013    /// preserved, so different orderings are different documents and must
1014    /// hash differently.
1015    #[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    /// Top-level array order matters too (e.g. `verificationMethod`,
1046    /// `authentication`, `updateKeys` in parameters). Same property as
1047    /// services but worth pinning down independently because these are
1048    /// strongly-typed `Vec` fields rather than raw JSON arrays.
1049    #[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    /// `get_did_document()` must NOT mutate the LogEntry. After calling it,
1072    /// the LogEntry's stored `state` must be unchanged (no implicit services
1073    /// folded in) and `generate_log_entry_hash()` must return the same value
1074    /// as before the call. This is the core hash-chain safety invariant: if
1075    /// `get_did_document()` ever mutated `state` in-place, every subsequent
1076    /// signature and version hash would diverge from every other
1077    /// implementation.
1078    #[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        // Building the resolution-time DID Document MUST be read-only.
1097        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        // The LogEntry itself must be untouched.
1106        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    // ===== create() tests =====
1116
1117    /// Tests that attempting to create a new log entry with the pre-ratification
1118    /// version (V1_0Pre) returns an error.
1119    /// Expected: Returns an error indicating version must be 1.0 or higher.
1120    /// This matters because new DIDs must only be created using the ratified spec;
1121    /// the pre-ratification format exists solely for backward-compatible reading.
1122    #[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    // ===== parse_version_id_fields() tests =====
1136
1137    /// Tests that a well-formed versionId string "3-abc123" is correctly split
1138    /// into the numeric version (3) and the hash component ("abc123").
1139    /// Expected: Returns (3, "abc123").
1140    /// This matters because the versionId encodes both the sequence number and
1141    /// the entry hash; correct parsing is required for version chain validation.
1142    #[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    /// Tests that a versionId string without a dash separator returns an error.
1150    /// Expected: Returns an error referencing "versionID" format.
1151    /// This matters because the "<number>-<hash>" format is mandated by the spec;
1152    /// strings that do not follow this pattern indicate a corrupted or malformed log.
1153    #[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    /// Tests that a versionId whose numeric portion is not a valid u32 returns
1160    /// an error.
1161    /// Expected: Returns an error about failing to parse the version ID.
1162    /// This matters because the version number must be a positive integer for
1163    /// sequential ordering of log entries; non-numeric values break chain validation.
1164    #[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    // ===== get_webvh_version() test =====
1171
1172    /// Tests that get_webvh_version correctly reports Version::V1_0 for a
1173    /// Spec1_0 log entry variant.
1174    /// Expected: Returns Version::V1_0.
1175    /// This matters because the version tag determines which serialization rules
1176    /// and validation logic apply when processing subsequent log entries.
1177    #[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}