1use crate::anti_tamper::{ClockStatus, HardwareFingerprint, LicenseState};
21use crate::container::RuntimeEnvironment;
22use crate::error::{LicenseError, Result};
23use crate::hardware::{detect_hardware, HardwareInfo};
24use crate::state_manager::StateManager;
25use chrono::{DateTime, Duration, Utc};
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use std::collections::HashMap;
29use std::env;
30use std::path::Path;
31
32pub const BUNDLE_VERSION: u8 = 1;
34
35pub const ENCRYPTED_BUNDLE_MAGIC: &[u8; 4] = b"LSBX";
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SupportBundle {
41 pub version: u8,
43
44 pub generated_at: DateTime<Utc>,
46
47 pub hardware: HardwareSummary,
49
50 pub clock_state: ClockState,
52
53 pub license_status: Option<LicenseStatusSummary>,
55
56 pub verification_log: Vec<VerificationEvent>,
58
59 pub environment: EnvironmentInfo,
61
62 pub state_files: StateFileSummary,
64
65 pub metadata: HashMap<String, String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct HardwareSummary {
72 pub mac_addresses_partial: Vec<String>,
74
75 pub mac_count: usize,
77
78 pub hostname_partial: Option<String>,
80
81 pub disk_count: usize,
83
84 pub disk_ids_hashed: Vec<String>,
86
87 pub has_machine_id: bool,
89
90 pub fingerprint_hash: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ClockState {
97 pub current_time: DateTime<Utc>,
99
100 pub last_validation_time: Option<DateTime<Utc>>,
102
103 pub drift_seconds: Option<i64>,
105
106 pub status: ClockStatusSummary,
108
109 pub timezone: Option<String>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub enum ClockStatusSummary {
116 Normal,
118 DriftedBackward { seconds: i64 },
120 JumpedForward { seconds: i64 },
122 NoPreviousState,
124 Unknown { reason: String },
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct LicenseStatusSummary {
131 pub id_hash: String,
133
134 pub product_id: String,
136
137 pub is_valid: bool,
139
140 pub days_remaining: i64,
142
143 pub expires_at: DateTime<Utc>,
145
146 pub issued_at: DateTime<Utc>,
148
149 pub has_hardware_binding: bool,
151
152 pub hardware_match: Option<HardwareMatchStatus>,
154
155 pub feature_count: usize,
157
158 pub validation_count: Option<u64>,
160
161 pub algorithm: String,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct HardwareMatchStatus {
168 pub percentage: f32,
170
171 pub matched_factors: Vec<String>,
173
174 pub unmatched_factors: Vec<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct VerificationEvent {
181 pub timestamp: DateTime<Utc>,
183
184 pub event_type: VerificationEventType,
186
187 pub message: String,
189
190 #[serde(skip_serializing_if = "HashMap::is_empty")]
192 pub context: HashMap<String, String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
197pub enum VerificationEventType {
198 Success,
200 SignatureFailure,
202 Expired,
204 NotYetValid,
206 HardwareMismatch,
208 ClockManipulation,
210 StateFileIssue,
212 Error,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct EnvironmentInfo {
219 pub os_name: String,
221
222 pub os_version: String,
224
225 pub architecture: String,
227
228 pub runtime: RuntimeEnvironmentSummary,
230
231 pub is_containerized: bool,
233
234 pub is_virtualized: bool,
236
237 pub cloud_provider: Option<String>,
239
240 pub licenz_version: String,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub enum RuntimeEnvironmentSummary {
247 Native,
249 Docker,
251 Kubernetes,
253 AwsEc2,
255 GcpCompute,
257 AzureVm,
259 GenericCloud,
261 Unknown,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, Default)]
267pub struct StateFileSummary {
268 pub locations_checked: usize,
270
271 pub valid_files: usize,
273
274 pub corrupted_or_missing: usize,
276
277 pub locations: Vec<StateFileLocation>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct StateFileLocation {
284 pub path: String,
286
287 pub status: StateFileLocationStatus,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub enum StateFileLocationStatus {
294 Valid,
296 Missing,
298 Corrupted,
300 Tampered,
302 Inaccessible { reason: String },
304}
305
306impl SupportBundle {
307 pub fn generate() -> Self {
309 let now = Utc::now();
310
311 Self {
312 version: BUNDLE_VERSION,
313 generated_at: now,
314 hardware: HardwareSummary::collect(),
315 clock_state: ClockState::collect(None),
316 license_status: None,
317 verification_log: Vec::new(),
318 environment: EnvironmentInfo::collect(),
319 state_files: StateFileSummary::default(),
320 metadata: HashMap::new(),
321 }
322 }
323
324 pub fn generate_with_license(license_id: &str, state_integrity_key: &[u8; 32]) -> Self {
326 let now = Utc::now();
327 let state_manager = StateManager::new(license_id, *state_integrity_key);
328
329 let state = state_manager.load(license_id).ok().flatten();
331
332 Self {
333 version: BUNDLE_VERSION,
334 generated_at: now,
335 hardware: HardwareSummary::collect(),
336 clock_state: ClockState::collect(state.as_ref()),
337 license_status: None, verification_log: Vec::new(),
339 environment: EnvironmentInfo::collect(),
340 state_files: StateFileSummary::collect(license_id, state_integrity_key),
341 metadata: HashMap::new(),
342 }
343 }
344
345 pub fn add_event(&mut self, event: VerificationEvent) {
347 self.verification_log.push(event);
348 }
349
350 pub fn add_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
352 self.metadata.insert(key.into(), value.into());
353 }
354
355 pub fn set_license_status(&mut self, status: LicenseStatusSummary) {
357 self.license_status = Some(status);
358 }
359
360 pub fn to_json(&self) -> Result<String> {
362 serde_json::to_string_pretty(self)
363 .map_err(|e| LicenseError::SerializationError(e.to_string()))
364 }
365
366 pub fn to_json_compact(&self) -> Result<String> {
368 serde_json::to_string(self).map_err(|e| LicenseError::SerializationError(e.to_string()))
369 }
370
371 pub fn from_json(json: &str) -> Result<Self> {
373 serde_json::from_str(json)
374 .map_err(|e| LicenseError::InvalidLicenseFormat(format!("Invalid bundle JSON: {}", e)))
375 }
376
377 pub fn encrypt(&self) -> Result<Vec<u8>> {
379 let json = self.to_json_compact()?;
380 encrypt_bundle(json.as_bytes())
381 }
382
383 pub fn decrypt(encrypted: &[u8]) -> Result<Self> {
385 let decrypted = decrypt_bundle(encrypted)?;
386 let json = String::from_utf8(decrypted)
387 .map_err(|e| LicenseError::InvalidLicenseFormat(format!("Invalid UTF-8: {}", e)))?;
388 Self::from_json(&json)
389 }
390
391 pub fn save(&self, path: &Path) -> Result<()> {
393 let json = self.to_json()?;
394 std::fs::write(path, json)?;
395 Ok(())
396 }
397
398 pub fn save_encrypted(&self, path: &Path) -> Result<()> {
400 let encrypted = self.encrypt()?;
401 std::fs::write(path, encrypted)?;
402 Ok(())
403 }
404
405 pub fn load(path: &Path) -> Result<Self> {
407 let data = std::fs::read(path)?;
408
409 if data.len() > 4 && &data[..4] == ENCRYPTED_BUNDLE_MAGIC {
411 Self::decrypt(&data)
412 } else {
413 let json = String::from_utf8(data)
414 .map_err(|e| LicenseError::InvalidLicenseFormat(format!("Invalid UTF-8: {}", e)))?;
415 Self::from_json(&json)
416 }
417 }
418}
419
420impl HardwareSummary {
421 pub fn collect() -> Self {
423 let hw = detect_hardware();
424 let fingerprint = HardwareFingerprint::from_hardware_info(&hw);
425
426 Self {
427 mac_addresses_partial: hw
428 .mac_addresses
429 .iter()
430 .map(|mac| sanitize_mac_address(mac))
431 .collect(),
432 mac_count: hw.mac_addresses.len(),
433 hostname_partial: hw.hostname.as_ref().map(|h| sanitize_hostname(h)),
434 disk_count: hw.disk_ids.len(),
435 disk_ids_hashed: hw.disk_ids.iter().map(|d| hash_identifier(d)).collect(),
436 has_machine_id: hw.machine_id.is_some(),
437 fingerprint_hash: fingerprint.combined_hash,
438 }
439 }
440
441 pub fn from_hardware_info(hw: &HardwareInfo) -> Self {
443 let fingerprint = HardwareFingerprint::from_hardware_info(hw);
444
445 Self {
446 mac_addresses_partial: hw
447 .mac_addresses
448 .iter()
449 .map(|mac| sanitize_mac_address(mac))
450 .collect(),
451 mac_count: hw.mac_addresses.len(),
452 hostname_partial: hw.hostname.as_ref().map(|h| sanitize_hostname(h)),
453 disk_count: hw.disk_ids.len(),
454 disk_ids_hashed: hw.disk_ids.iter().map(|d| hash_identifier(d)).collect(),
455 has_machine_id: hw.machine_id.is_some(),
456 fingerprint_hash: fingerprint.combined_hash,
457 }
458 }
459}
460
461impl ClockState {
462 pub fn collect(state: Option<&LicenseState>) -> Self {
464 let now = Utc::now();
465
466 let (last_validation_time, drift_seconds, status) = match state {
467 Some(s) => {
468 let drift = (now - s.last_system_time).num_seconds();
469
470 let status = match s.detect_clock_manipulation(Duration::hours(1)) {
471 Ok(ClockStatus::Ok { .. }) => ClockStatusSummary::Normal,
472 Ok(ClockStatus::Backwards { drift, .. }) => {
473 ClockStatusSummary::DriftedBackward {
474 seconds: drift.num_seconds(),
475 }
476 }
477 Ok(ClockStatus::SuspiciousJump { jump, .. }) => {
478 ClockStatusSummary::JumpedForward {
479 seconds: jump.num_seconds(),
480 }
481 }
482 Err(e) => ClockStatusSummary::Unknown {
483 reason: e.to_string(),
484 },
485 };
486
487 (Some(s.last_system_time), Some(drift), status)
488 }
489 None => (None, None, ClockStatusSummary::NoPreviousState),
490 };
491
492 Self {
493 current_time: now,
494 last_validation_time,
495 drift_seconds,
496 status,
497 timezone: env::var("TZ").ok(),
498 }
499 }
500}
501
502impl EnvironmentInfo {
503 pub fn collect() -> Self {
505 let runtime_env = RuntimeEnvironment::detect();
506
507 let runtime = match runtime_env {
508 RuntimeEnvironment::Standalone => RuntimeEnvironmentSummary::Native,
509 RuntimeEnvironment::Docker => RuntimeEnvironmentSummary::Docker,
510 RuntimeEnvironment::Kubernetes => RuntimeEnvironmentSummary::Kubernetes,
511 RuntimeEnvironment::AwsEc2 => RuntimeEnvironmentSummary::AwsEc2,
512 RuntimeEnvironment::GcpCompute => RuntimeEnvironmentSummary::GcpCompute,
513 RuntimeEnvironment::AzureVm => RuntimeEnvironmentSummary::AzureVm,
514 RuntimeEnvironment::GenericCloud => RuntimeEnvironmentSummary::GenericCloud,
515 };
516
517 let is_containerized = matches!(
518 runtime_env,
519 RuntimeEnvironment::Docker | RuntimeEnvironment::Kubernetes
520 );
521
522 let is_virtualized = matches!(
523 runtime_env,
524 RuntimeEnvironment::AwsEc2
525 | RuntimeEnvironment::GcpCompute
526 | RuntimeEnvironment::AzureVm
527 | RuntimeEnvironment::GenericCloud
528 );
529
530 let cloud_provider = match runtime_env {
531 RuntimeEnvironment::AwsEc2 => Some("AWS".to_string()),
532 RuntimeEnvironment::GcpCompute => Some("GCP".to_string()),
533 RuntimeEnvironment::AzureVm => Some("Azure".to_string()),
534 _ => None,
535 };
536
537 Self {
538 os_name: env::consts::OS.to_string(),
539 os_version: get_os_version(),
540 architecture: env::consts::ARCH.to_string(),
541 runtime,
542 is_containerized,
543 is_virtualized,
544 cloud_provider,
545 licenz_version: crate::VERSION.to_string(),
546 }
547 }
548}
549
550impl StateFileSummary {
551 pub fn collect(license_id: &str, state_integrity_key: &[u8; 32]) -> Self {
553 let state_manager = StateManager::new(license_id, *state_integrity_key);
554 let paths = state_manager.paths();
555
556 let mut valid_count = 0;
557 let mut invalid_count = 0;
558 let mut locations = Vec::new();
559
560 for path in paths {
561 let path_str = sanitize_path(&path.display().to_string());
562
563 if !path.exists() {
564 invalid_count += 1;
565 locations.push(StateFileLocation {
566 path: path_str,
567 status: StateFileLocationStatus::Missing,
568 });
569 } else {
570 match LicenseState::load(path, license_id, state_integrity_key) {
571 Ok(Some(_)) => {
572 valid_count += 1;
573 locations.push(StateFileLocation {
574 path: path_str,
575 status: StateFileLocationStatus::Valid,
576 });
577 }
578 Ok(None) => {
579 invalid_count += 1;
580 locations.push(StateFileLocation {
581 path: path_str,
582 status: StateFileLocationStatus::Missing,
583 });
584 }
585 Err(LicenseError::StateFileTampered) => {
586 invalid_count += 1;
587 locations.push(StateFileLocation {
588 path: path_str,
589 status: StateFileLocationStatus::Tampered,
590 });
591 }
592 Err(e) => {
593 invalid_count += 1;
594 locations.push(StateFileLocation {
595 path: path_str,
596 status: StateFileLocationStatus::Inaccessible {
597 reason: e.to_string(),
598 },
599 });
600 }
601 }
602 }
603 }
604
605 Self {
606 locations_checked: paths.len(),
607 valid_files: valid_count,
608 corrupted_or_missing: invalid_count,
609 locations,
610 }
611 }
612}
613
614impl VerificationEvent {
615 pub fn new(event_type: VerificationEventType, message: impl Into<String>) -> Self {
617 Self {
618 timestamp: Utc::now(),
619 event_type,
620 message: message.into(),
621 context: HashMap::new(),
622 }
623 }
624
625 pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
627 self.context.insert(key.into(), value.into());
628 self
629 }
630}
631
632fn sanitize_mac_address(mac: &str) -> String {
638 let parts: Vec<&str> = mac.split(':').collect();
639 if parts.len() >= 3 {
640 format!(
641 "{}:{}:{}:XX:XX:XX",
642 parts[0].to_uppercase(),
643 parts[1].to_uppercase(),
644 parts[2].to_uppercase()
645 )
646 } else {
647 let hash = hash_identifier(mac);
649 format!("MAC-{}", &hash[..8])
650 }
651}
652
653fn sanitize_hostname(hostname: &str) -> String {
655 let visible_chars = hostname.chars().take(4).collect::<String>();
656 let hash = hash_identifier(hostname);
657 format!("{}...{}", visible_chars, &hash[..6])
658}
659
660fn hash_identifier(value: &str) -> String {
662 let mut hasher = Sha256::new();
663 hasher.update(value.as_bytes());
664 hex::encode(hasher.finalize())
665}
666
667fn sanitize_path(path: &str) -> String {
669 let path = if let Some(home) = dirs_next::home_dir() {
671 path.replace(&home.display().to_string(), "~")
672 } else {
673 path.to_string()
674 };
675
676 path.replace("C:\\Users\\", "~\\")
679 .replace("/home/", "~/")
680 .replace("/Users/", "~/")
681}
682
683fn get_os_version() -> String {
685 #[cfg(target_os = "linux")]
687 {
688 if let Ok(contents) = std::fs::read_to_string("/etc/os-release") {
689 for line in contents.lines() {
690 if line.starts_with("PRETTY_NAME=") {
691 return line
692 .trim_start_matches("PRETTY_NAME=")
693 .trim_matches('"')
694 .to_string();
695 }
696 }
697 }
698 }
699
700 #[cfg(target_os = "macos")]
701 {
702 if let Ok(output) = std::process::Command::new("sw_vers")
703 .arg("-productVersion")
704 .output()
705 {
706 return String::from_utf8_lossy(&output.stdout).trim().to_string();
707 }
708 }
709
710 #[cfg(target_os = "windows")]
711 {
712 if let Ok(output) = std::process::Command::new("cmd")
713 .args(["/C", "ver"])
714 .output()
715 {
716 return String::from_utf8_lossy(&output.stdout).trim().to_string();
717 }
718 }
719
720 "unknown".to_string()
721}
722
723fn encrypt_bundle(data: &[u8]) -> Result<Vec<u8>> {
729 use aes_gcm::{
730 aead::{Aead, KeyInit},
731 Aes256Gcm, Nonce,
732 };
733
734 let fingerprint = HardwareFingerprint::generate();
736 let key = derive_bundle_key(&fingerprint.combined_hash);
737
738 let nonce_bytes: [u8; 12] = rand::random();
740 let nonce = Nonce::from_slice(&nonce_bytes);
741
742 let cipher = Aes256Gcm::new_from_slice(&key)
744 .map_err(|e| LicenseError::KeyGenerationFailed(e.to_string()))?;
745
746 let encrypted = cipher
747 .encrypt(nonce, data)
748 .map_err(|e| LicenseError::KeyGenerationFailed(format!("Encryption failed: {}", e)))?;
749
750 let mut output = Vec::with_capacity(4 + 1 + 12 + encrypted.len());
752 output.extend_from_slice(ENCRYPTED_BUNDLE_MAGIC);
753 output.push(BUNDLE_VERSION);
754 output.extend_from_slice(&nonce_bytes);
755 output.extend_from_slice(&encrypted);
756
757 Ok(output)
758}
759
760fn decrypt_bundle(data: &[u8]) -> Result<Vec<u8>> {
762 use aes_gcm::{
763 aead::{Aead, KeyInit},
764 Aes256Gcm, Nonce,
765 };
766
767 if data.len() < 17 {
769 return Err(LicenseError::InvalidLicenseFormat(
770 "Encrypted bundle too short".into(),
771 ));
772 }
773
774 if &data[..4] != ENCRYPTED_BUNDLE_MAGIC {
775 return Err(LicenseError::InvalidLicenseFormat(
776 "Invalid encrypted bundle format".into(),
777 ));
778 }
779
780 let version = data[4];
781 if version != BUNDLE_VERSION {
782 return Err(LicenseError::InvalidLicenseFormat(format!(
783 "Unsupported bundle version: {}",
784 version
785 )));
786 }
787
788 let nonce_bytes: [u8; 12] = data[5..17]
789 .try_into()
790 .map_err(|_| LicenseError::InvalidLicenseFormat("Invalid nonce".into()))?;
791
792 let encrypted = &data[17..];
793
794 let fingerprint = HardwareFingerprint::generate();
796 let key = derive_bundle_key(&fingerprint.combined_hash);
797
798 let cipher = Aes256Gcm::new_from_slice(&key)
800 .map_err(|e| LicenseError::KeyGenerationFailed(e.to_string()))?;
801
802 let nonce = Nonce::from_slice(&nonce_bytes);
803
804 cipher.decrypt(nonce, encrypted).map_err(|_| {
805 LicenseError::InvalidLicenseFormat(
806 "Decryption failed - bundle may be from a different machine".into(),
807 )
808 })
809}
810
811fn derive_bundle_key(fingerprint_hash: &str) -> [u8; 32] {
813 let mut hasher = Sha256::new();
814 hasher.update(b"licenz-support-bundle-v1:");
815 hasher.update(fingerprint_hash.as_bytes());
816
817 let result = hasher.finalize();
818 let mut key = [0u8; 32];
819 key.copy_from_slice(&result);
820 key
821}
822
823pub struct SupportBundleBuilder {
829 bundle: SupportBundle,
830}
831
832impl SupportBundleBuilder {
833 pub fn new() -> Self {
835 Self {
836 bundle: SupportBundle::generate(),
837 }
838 }
839
840 pub fn with_license_id(license_id: &str, state_integrity_key: &[u8; 32]) -> Self {
842 Self {
843 bundle: SupportBundle::generate_with_license(license_id, state_integrity_key),
844 }
845 }
846
847 pub fn license_status(mut self, status: LicenseStatusSummary) -> Self {
849 self.bundle.license_status = Some(status);
850 self
851 }
852
853 pub fn event(mut self, event: VerificationEvent) -> Self {
855 self.bundle.verification_log.push(event);
856 self
857 }
858
859 pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
861 self.bundle.metadata.insert(key.into(), value.into());
862 self
863 }
864
865 pub fn build(self) -> SupportBundle {
867 self.bundle
868 }
869}
870
871impl Default for SupportBundleBuilder {
872 fn default() -> Self {
873 Self::new()
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880
881 #[test]
882 fn test_generate_basic_bundle() {
883 let bundle = SupportBundle::generate();
884
885 assert_eq!(bundle.version, BUNDLE_VERSION);
886 let _ = bundle.hardware.mac_count;
888 assert!(!bundle.environment.licenz_version.is_empty());
889 }
890
891 #[test]
892 fn test_sanitize_mac_address() {
893 let mac = "AA:BB:CC:DD:EE:FF";
894 let sanitized = sanitize_mac_address(mac);
895 assert_eq!(sanitized, "AA:BB:CC:XX:XX:XX");
896 assert!(!sanitized.contains("DD"));
897 assert!(!sanitized.contains("EE"));
898 assert!(!sanitized.contains("FF"));
899 }
900
901 #[test]
902 fn test_sanitize_hostname() {
903 let hostname = "my-production-server";
904 let sanitized = sanitize_hostname(hostname);
905 assert!(sanitized.starts_with("my-p"));
906 assert!(sanitized.contains("..."));
907 }
908
909 #[test]
910 fn test_bundle_json_round_trip() {
911 let bundle = SupportBundle::generate();
912 let json = bundle.to_json().unwrap();
913 let parsed = SupportBundle::from_json(&json).unwrap();
914
915 assert_eq!(bundle.version, parsed.version);
916 assert_eq!(
917 bundle.hardware.fingerprint_hash,
918 parsed.hardware.fingerprint_hash
919 );
920 }
921
922 #[test]
923 fn test_bundle_encryption_round_trip() {
924 let bundle = SupportBundle::generate();
925 let encrypted = bundle.encrypt().unwrap();
926
927 assert!(encrypted.starts_with(ENCRYPTED_BUNDLE_MAGIC));
928
929 let decrypted = SupportBundle::decrypt(&encrypted).unwrap();
930 assert_eq!(bundle.version, decrypted.version);
931 }
932
933 #[test]
934 fn test_verification_event() {
935 let event = VerificationEvent::new(VerificationEventType::Success, "License validated")
936 .with_context("license_id", "LIC-001");
937
938 assert!(matches!(event.event_type, VerificationEventType::Success));
939 assert_eq!(
940 event.context.get("license_id"),
941 Some(&"LIC-001".to_string())
942 );
943 }
944
945 #[test]
946 fn test_builder_pattern() {
947 let bundle = SupportBundleBuilder::new()
948 .metadata("support_ticket", "TICKET-12345")
949 .event(VerificationEvent::new(
950 VerificationEventType::Error,
951 "Test error",
952 ))
953 .build();
954
955 assert_eq!(
956 bundle.metadata.get("support_ticket"),
957 Some(&"TICKET-12345".to_string())
958 );
959 assert_eq!(bundle.verification_log.len(), 1);
960 }
961}