High-performance implementation of plcbundle written in Rust
at main 2231 lines 77 kB view raw
1// DID Resolution and Query commands 2use anyhow::{Context, Result}; 3use clap::{Args, Subcommand, ValueHint}; 4use plcbundle::BundleManager; 5use sonic_rs::{JsonContainerTrait, JsonValueTrait}; 6use std::path::PathBuf; 7 8#[derive(Args)] 9#[command( 10 about = "DID operations and queries", 11 long_about = "Query and analyze DIDs stored in the bundle repository. These commands provide 12comprehensive DID functionality including resolution to W3C DID documents, operation 13history lookup, and cryptographic validation of DID operation chains. 14 15All commands require a DID index to be built for optimal performance. The index 16enables fast O(1) lookups by mapping DIDs to their bundle locations. Use 'index build' 17to create the index if it doesn't exist. 18 19The 'resolve' subcommand converts a DID (or handle) into a complete W3C DID document 20by following the operation chain and applying all operations. The 'log' subcommand 21shows all operations for a DID in chronological order. The 'audit' subcommand performs 22cryptographic validation of the entire operation chain, detecting forks and verifying 23signatures. 24 25These commands are essential for DID-based applications, identity verification, 26and understanding how DIDs evolve over time through their operation history.", 27 help_template = crate::clap_help!( 28 examples: " # Resolve DID to current document\n \ 29 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe\n\n \ 30 # Show DID operation log\n \ 31 {bin} did log did:plc:524tuhdhh3m7li5gycdn6boe\n\n \ 32 # Show complete audit log\n \ 33 {bin} did history did:plc:524tuhdhh3m7li5gycdn6boe\n\n \ 34 # Validate DID chain\n \ 35 {bin} did validate did:plc:524tuhdhh3m7li5gycdn6boe" 36 ) 37)] 38pub struct DidCommand { 39 #[command(subcommand)] 40 pub command: DIDCommands, 41} 42 43#[derive(Args)] 44#[command( 45 about = "Resolve handle to DID", 46 long_about = "Resolve an AT Protocol handle (e.g., example.bsky.social) to its 47corresponding DID using a handle resolver service. 48 49Handles are human-readable identifiers that map to DIDs, which are the 50cryptographic identifiers used in the PLC directory. This command queries 51the handle resolver to perform the lookup and displays the resulting DID. 52 53By default uses the quickdid.smokesignal.tools resolver, but you can specify 54a custom resolver URL with --handle-resolver if needed. This is useful for 55testing with different resolver implementations or when the default resolver 56is unavailable." 57)] 58pub struct HandleCommand { 59 /// Handle to resolve (e.g., tree.fail) 60 pub handle: String, 61 62 /// Handle resolver URL (defaults to quickdid.smokesignal.tools) 63 #[arg(long, value_hint = ValueHint::Url)] 64 pub handle_resolver: Option<String>, 65} 66 67#[derive(Subcommand)] 68pub enum DIDCommands { 69 /// Resolve DID to current W3C DID document 70 /// 71 /// By default, pretty-prints with colors when outputting to a terminal. 72 /// Use --raw to force raw JSON output (useful for piping). 73 /// Use -q/--query to extract a value using JMESPath. 74 #[command( 75 alias = "doc", 76 alias = "document", 77 long_about = "Resolve a DID or handle to its current W3C DID Document.\n\n\ 78This command follows the chain of operations for a DID to construct the most\n\ 79up-to-date version of its DID document. It supports resolving both DIDs\n\ 80(e.g., did:plc:...) and handles (e.g., example.bsky.social).\n\n\ 81When a handle is provided, it is first resolved to a DID using a handle resolver\n\ 82service. The resulting DID is then used to construct the document.\n\n\ 83The command checks the mempool for the most recent operations before falling back\n\ 84to the main bundle index. This ensures that the resolved document is always\n\ 85current, even if the latest operations have not yet been incorporated into a bundle.", 86 help_template = crate::clap_help!( 87 examples: " # Resolve DID to full document\n \ 88 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe\n\n \ 89 # Query with JMESPath\n \ 90 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe -q 'id'\n \ 91 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe -q 'verificationMethod[0].id'\n \ 92 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe -q 'service[].id'\n\n \ 93 # Force raw JSON output\n \ 94 {bin} did resolve did:plc:524tuhdhh3m7li5gycdn6boe --raw" 95 ) 96 )] 97 Resolve { 98 /// DID or handle to resolve 99 #[arg(value_name = "DID")] 100 did: Option<String>, 101 102 /// Handle resolver URL (e.g., https://quickdid.smokesignal.tools) 103 #[arg(long, value_hint = ValueHint::Url)] 104 handle_resolver: Option<String>, 105 106 /// Query DID document JSON using JMESPath expression 107 /// 108 /// Extracts a value from the DID document using JMESPath. 109 /// Examples: 'id', 'verificationMethod[0].id', 'service[].id' 110 #[arg(short = 'q', long = "query")] 111 query: Option<String>, 112 113 /// Force raw JSON output (no pretty printing, no colors) 114 /// 115 /// By default, output is pretty-printed with colors when writing to a terminal. 116 /// Use this flag to force raw JSON output, useful for piping to other tools. 117 #[arg(long = "raw")] 118 raw: bool, 119 120 /// Compare DID document with remote PLC directory 121 /// 122 /// Fetches the DID document from the remote PLC directory and compares it 123 /// with the document resolved from local bundles. Shows differences if any. 124 /// If URL is not provided, uses the repository origin from the local index. 125 #[arg(long, value_name = "URL", num_args = 0..=1, value_hint = ValueHint::Url)] 126 compare: Option<Option<String>>, 127 }, 128 129 /// Show DID operation log 130 #[command(alias = "lookup", alias = "find", alias = "get", alias = "history")] 131 Log { 132 /// DID to show log for 133 did: String, 134 135 /// Output as JSON 136 #[arg(long)] 137 json: bool, 138 139 /// Output format: bundle,position,global,status,cid,created_at,nullified 140 /// 141 /// Available fields: 142 /// - bundle: bundle number 143 /// - position: position within bundle 144 /// - global/global_pos: global position (bundle * 10000 + position) 145 /// - status: operation status (✓ or ✗) 146 /// - cid: operation CID 147 /// - created_at/created/date/time: creation timestamp 148 /// - nullified: nullified flag 149 #[arg(long, default_value = "bundle,position,global,status,cid,created_at")] 150 format: String, 151 152 /// Omit header row 153 #[arg(long)] 154 no_header: bool, 155 156 /// Field separator (default: tab) 157 #[arg(long, default_value = "\t")] 158 separator: String, 159 160 /// Reverse order (show newest first) 161 #[arg(long)] 162 reverse: bool, 163 }, 164 165 /// Process multiple DIDs from file or stdin (TODO) 166 Batch { 167 /// Action: lookup, resolve, export 168 #[arg(long, default_value = "lookup")] 169 action: String, 170 171 /// Number of parallel threads (0 = auto-detect) 172 #[arg(short = 'j', long, default_value = "0")] 173 threads: usize, 174 175 /// Output file 176 #[arg(short, long, value_hint = ValueHint::FilePath)] 177 output: Option<PathBuf>, 178 179 /// Read from stdin 180 #[arg(long)] 181 stdin: bool, 182 }, 183 184 /// Audit DID operation chain from local bundles 185 #[command(alias = "validate")] 186 Audit { 187 /// DID to audit (e.g., did:plc:ewvi7nxzyoun6zhxrhs64oiz) 188 did: String, 189 190 /// Show verbose output including all operations 191 #[arg(short, long)] 192 verbose: bool, 193 194 /// Only show summary (no operation details) 195 #[arg(short, long)] 196 quiet: bool, 197 198 /// Show fork visualization 199 #[arg(long)] 200 fork_viz: bool, 201 }, 202} 203 204pub fn run_did(cmd: DidCommand, dir: PathBuf, global_verbose: bool) -> Result<()> { 205 match cmd.command { 206 DIDCommands::Resolve { 207 did, 208 handle_resolver, 209 query, 210 raw, 211 compare, 212 } => { 213 cmd_did_resolve( 214 dir, 215 did, 216 handle_resolver, 217 global_verbose, 218 query, 219 raw, 220 compare, 221 )?; 222 } 223 DIDCommands::Log { 224 did, 225 json, 226 format, 227 no_header, 228 separator, 229 reverse, 230 } => { 231 cmd_did_lookup( 232 dir, 233 did, 234 global_verbose, 235 json, 236 format, 237 no_header, 238 separator, 239 reverse, 240 )?; 241 } 242 DIDCommands::Batch { 243 action, 244 threads, 245 output, 246 stdin, 247 } => { 248 cmd_did_batch(dir, action, threads, output, stdin)?; 249 } 250 DIDCommands::Audit { 251 did, 252 verbose, 253 quiet, 254 fork_viz, 255 } => { 256 cmd_did_validate(dir, did, verbose, quiet, fork_viz)?; 257 } 258 } 259 Ok(()) 260} 261 262pub fn run_handle(cmd: HandleCommand, dir: PathBuf) -> Result<()> { 263 cmd_did_handle(dir, cmd.handle, cmd.handle_resolver)?; 264 Ok(()) 265} 266 267// DID RESOLVE - Convert DID to W3C DID Document 268 269pub fn cmd_did_resolve( 270 dir: PathBuf, 271 input: Option<String>, 272 handle_resolver_url: Option<String>, 273 verbose: bool, 274 query: Option<String>, 275 raw: bool, 276 compare: Option<Option<String>>, 277) -> Result<()> { 278 let input = input.ok_or_else(|| anyhow::anyhow!("DID or handle is required"))?; 279 use plcbundle::constants; 280 281 // Use default resolver if none provided 282 let resolver_url = 283 handle_resolver_url.or_else(|| Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string())); 284 285 // Initialize manager with handle resolver and preload mempool (for resolve command) 286 let options = plcbundle::ManagerOptions { 287 handle_resolver_url: resolver_url, 288 preload_mempool: true, 289 verbose: false, 290 }; 291 let manager = BundleManager::new(dir, options)?; 292 293 // Resolve handle to DID if needed 294 let (did, handle_resolve_time) = manager.resolve_handle_or_did(&input)?; 295 296 if handle_resolve_time > 0 { 297 log::info!( 298 "Resolved handle: {} → {} (in {}ms)", 299 input, 300 did, 301 handle_resolve_time 302 ); 303 } else { 304 log::info!("Resolving DID: {}", did); 305 } 306 307 // Get resolve result with stats 308 let result = manager.resolve_did(&did)?; 309 310 // If compare is enabled, fetch remote document and compare 311 if let Some(maybe_url) = compare { 312 use crate::plc_client::PLCClient; 313 use std::time::Instant; 314 use tokio::runtime::Runtime; 315 316 // Use provided URL, or use repository origin, or fall back to default 317 let plc_url = match maybe_url { 318 Some(url) if !url.is_empty() => { 319 if verbose { 320 log::info!("Using provided PLC directory URL: {}", url); 321 } 322 url 323 } 324 _ => { 325 // Get origin from local repository index 326 let local_index = manager.get_index(); 327 let origin = &local_index.origin; 328 329 // If origin is "local" or empty, use default PLC directory 330 if origin == "local" || origin.is_empty() { 331 if verbose { 332 log::info!( 333 "Origin is '{}', using default PLC directory: {}", 334 origin, 335 constants::DEFAULT_PLC_DIRECTORY_URL 336 ); 337 } 338 constants::DEFAULT_PLC_DIRECTORY_URL.to_string() 339 } else { 340 if verbose { 341 log::info!("Using repository origin as PLC directory: {}", origin); 342 } 343 origin.clone() 344 } 345 } 346 }; 347 348 eprintln!("🔍 Comparing with remote PLC directory..."); 349 350 if verbose { 351 log::info!("Target PLC directory: {}", plc_url); 352 log::info!("DID to fetch: {}", did); 353 } 354 355 let fetch_start = Instant::now(); 356 let rt = Runtime::new().context("Failed to create tokio runtime")?; 357 use plcbundle::resolver::DIDDocument; 358 let (remote_doc, remote_json_raw): (DIDDocument, String) = rt 359 .block_on(async { 360 let client = PLCClient::new(&plc_url).context("Failed to create PLC client")?; 361 if verbose { 362 log::info!("Created PLC client, fetching DID document..."); 363 } 364 // Fetch both the parsed document and raw JSON for accurate comparison 365 let raw_json = client.fetch_did_document_raw(&did).await?; 366 let parsed_doc = client.fetch_did_document(&did).await?; 367 Ok::<(DIDDocument, String), anyhow::Error>((parsed_doc, raw_json)) 368 }) 369 .context("Failed to fetch DID document from remote PLC directory")?; 370 let fetch_duration = fetch_start.elapsed(); 371 372 if verbose { 373 log::info!("Fetched DID document in {:?}", fetch_duration); 374 } 375 376 eprintln!("✅ Fetched remote document from {}", plc_url); 377 eprintln!(); 378 379 // Compare documents and return early (don't show full document) 380 compare_did_documents( 381 &result.document, 382 &remote_doc, 383 &remote_json_raw, 384 &did, 385 &plc_url, 386 fetch_duration, 387 )?; 388 return Ok(()); 389 } 390 391 // Get DID index for shard calculation (only for PLC DIDs) 392 if let Some(identifier) = did.strip_prefix("did:plc:") { 393 let shard_num = calculate_shard_for_display(identifier); 394 log::debug!( 395 "DID {} -> identifier '{}' -> shard {:02x}", 396 did, 397 identifier, 398 shard_num 399 ); 400 } 401 402 if let Some(stats) = &result.shard_stats { 403 log::debug!( 404 "Shard {:02x} loaded, size: {} bytes", 405 result.shard_num, 406 stats.shard_size 407 ); 408 409 let reduction = if stats.total_entries > 0 { 410 ((stats.total_entries - stats.prefix_narrowed_to) as f64 / stats.total_entries as f64) 411 * 100.0 412 } else { 413 0.0 414 }; 415 416 log::debug!( 417 "Prefix index narrowed search: {} entries → {} entries ({:.1}% reduction)", 418 stats.total_entries, 419 stats.prefix_narrowed_to, 420 reduction 421 ); 422 log::debug!( 423 "Binary search found {} locations after {} attempts", 424 stats.locations_found, 425 stats.binary_search_attempts 426 ); 427 } 428 429 if verbose { 430 // Convert handle resolution time to Duration 431 let handle_resolve_duration = std::time::Duration::from_millis(handle_resolve_time); 432 433 if handle_resolve_time > 0 { 434 log::info!("Handle resolution: {:?}", handle_resolve_duration); 435 } 436 437 // Show mempool lookup time 438 log::info!("Mempool check: {:?}", result.mempool_time); 439 440 // Show detailed index lookup timings if available 441 if let Some(ref timings) = result.lookup_timings { 442 log::info!(""); 443 log::info!("Index Lookup Breakdown:"); 444 log::info!(" Extract ID: {:?}", timings.extract_identifier); 445 log::info!(" Calc shard: {:?}", timings.calculate_shard); 446 log::info!( 447 " Load shard: {:?} ({})", 448 timings.load_shard, 449 if timings.cache_hit { 450 "cache hit" 451 } else { 452 "cache miss" 453 } 454 ); 455 456 // Search breakdown 457 log::info!(" Search:"); 458 if let Some(ref base_time) = timings.base_search_time { 459 log::info!(" Base shard: {:?}", base_time); 460 } 461 if !timings.delta_segment_times.is_empty() { 462 let total_delta_time: std::time::Duration = timings 463 .delta_segment_times 464 .iter() 465 .map(|(_, time)| *time) 466 .sum(); 467 log::info!( 468 " Delta segs: {:?} ({} segment{})", 469 total_delta_time, 470 timings.delta_segment_times.len(), 471 if timings.delta_segment_times.len() == 1 { 472 "" 473 } else { 474 "s" 475 } 476 ); 477 478 // Show individual delta segments if there are multiple or if verbose 479 if timings.delta_segment_times.len() > 1 || verbose { 480 for (seg_name, seg_time) in &timings.delta_segment_times { 481 log::info!(" - {}: {:?}", seg_name, seg_time); 482 } 483 } 484 } 485 if timings.merge_time.as_nanos() > 0 { 486 log::info!(" Merge/sort: {:?}", timings.merge_time); 487 } 488 log::info!(" Search total: {:?}", timings.search); 489 log::info!(" Index total: {:?}", result.index_time); 490 } else { 491 log::info!("Index: {:?}", result.index_time); 492 } 493 494 log::info!("Load operation: {:?}", result.load_time); 495 496 // Calculate true total including handle resolution 497 // Note: result.total_time already includes mempool_time + index_time + load_time 498 let true_total = handle_resolve_duration + result.total_time; 499 let did_resolve_time = result.mempool_time + result.index_time + result.load_time; 500 log::info!( 501 "Total: {:?} (handle: {:?} + did: {:?})", 502 true_total, 503 handle_resolve_duration, 504 did_resolve_time 505 ); 506 log::info!( 507 " DID resolve breakdown: mempool: {:?} + index: {:?} + load: {:?}", 508 result.mempool_time, 509 result.index_time, 510 result.load_time 511 ); 512 513 // Calculate global position and display source 514 if result.bundle_number == 0 { 515 // Operation from mempool 516 let index = manager.get_index(); 517 let global_pos = plcbundle::constants::mempool_position_to_global( 518 index.last_bundle, 519 result.position, 520 ); 521 log::info!( 522 "Source: mempool position {} (global: {})\n", 523 result.position, 524 global_pos 525 ); 526 } else { 527 // Operation from bundle 528 let global_pos = plcbundle::constants::bundle_position_to_global( 529 result.bundle_number, 530 result.position, 531 ); 532 log::info!( 533 "Source: bundle {}, position {} (global: {})\n", 534 result.bundle_number, 535 result.position, 536 global_pos 537 ); 538 } 539 } 540 541 // Convert document to JSON string 542 let document_json = sonic_rs::to_string(&result.document)?; 543 544 // If query is provided, apply JMESPath query 545 let output_json = if let Some(query_expr) = query { 546 // Compile JMESPath expression 547 let expr = jmespath::compile(&query_expr).map_err(|e| { 548 anyhow::anyhow!("Failed to compile JMESPath query '{}': {}", query_expr, e) 549 })?; 550 551 // Execute query 552 let data = jmespath::Variable::from_json(&document_json) 553 .map_err(|e| anyhow::anyhow!("Failed to parse DID document JSON: {}", e))?; 554 555 let result = expr 556 .search(&data) 557 .map_err(|e| anyhow::anyhow!("JMESPath query failed: {}", e))?; 558 559 if result.is_null() { 560 anyhow::bail!("Query '{}' returned null (field not found)", query_expr); 561 } 562 563 // Convert result to JSON string 564 // Note: jmespath uses serde_json internally, so we use serde_json here (not bundle/operation data) 565 if result.is_string() { 566 result.as_string().unwrap().to_string() 567 } else { 568 serde_json::to_string(&*result) 569 .map_err(|e| anyhow::anyhow!("Failed to serialize query result: {}", e))? 570 } 571 } else { 572 document_json 573 }; 574 575 // Output document (always to stdout) 576 // Determine if we should pretty print: 577 // - Pretty print if stdout is a TTY (interactive terminal) and --raw is not set 578 // - Use raw output if --raw is set or if output is piped (not a TTY) 579 #[cfg(feature = "cli")] 580 let should_pretty = !raw && super::utils::is_stdout_tty(); 581 #[cfg(not(feature = "cli"))] 582 let should_pretty = !raw; 583 584 if should_pretty { 585 // Try to parse and pretty print the result 586 match sonic_rs::from_str::<sonic_rs::Value>(&output_json) { 587 Ok(parsed) => { 588 let pretty_json = sonic_rs::to_string_pretty(&parsed)?; 589 #[cfg(feature = "cli")] 590 { 591 println!("{}", super::utils::colorize_json(&pretty_json)); 592 } 593 #[cfg(not(feature = "cli"))] 594 { 595 println!("{}", pretty_json); 596 } 597 } 598 Err(_) => { 599 // If it's not valid JSON (e.g., a string result), just output as-is 600 println!("{}", output_json); 601 } 602 } 603 } else { 604 // Raw JSON output (compact, no colors) 605 println!("{}", output_json); 606 } 607 608 Ok(()) 609} 610 611/// Compare two DID documents and show differences 612fn compare_did_documents( 613 local: &plcbundle::resolver::DIDDocument, 614 _remote: &plcbundle::resolver::DIDDocument, 615 remote_json_raw: &str, 616 _did: &str, 617 remote_url: &str, 618 fetch_duration: std::time::Duration, 619) -> Result<()> { 620 use sha2::{Digest, Sha256}; 621 622 eprintln!("📊 Document Comparison"); 623 eprintln!("═══════════════════════"); 624 625 // Construct the full URL that was fetched 626 let full_url = format!("{}/{}", remote_url.trim_end_matches('/'), _did); 627 eprintln!(" Remote URL: {}", full_url); 628 eprintln!(" Fetch time: {:?}", fetch_duration); 629 eprintln!(); 630 631 // Serialize local document (respects skip_serializing_if attributes) 632 let local_json = serde_json::to_string(local)?; 633 634 // Normalize both JSON strings by parsing and re-serializing with consistent formatting 635 // This ensures we compare equivalent JSON structures, handling: 636 // - Key ordering differences 637 // - Whitespace differences 638 // - Empty array representation (preserved from raw remote JSON) 639 let local_normalized = normalize_json_for_comparison(&local_json)?; 640 let remote_normalized = normalize_json_for_comparison(remote_json_raw)?; 641 642 // Calculate SHA256 hashes of normalized JSON 643 let mut local_hasher = Sha256::new(); 644 local_hasher.update(local_normalized.as_bytes()); 645 let local_hash = local_hasher.finalize(); 646 let local_hash_hex = format!("{:x}", local_hash); 647 648 let mut remote_hasher = Sha256::new(); 649 remote_hasher.update(remote_normalized.as_bytes()); 650 let remote_hash = remote_hasher.finalize(); 651 let remote_hash_hex = format!("{:x}", remote_hash); 652 653 eprintln!(" Local hash: {}", local_hash_hex); 654 eprintln!(" Remote hash: {}", remote_hash_hex); 655 eprintln!(); 656 657 // Compare hashes 658 if local_hash == remote_hash { 659 eprintln!("✅ Documents are identical (SHA256 hashes match)"); 660 eprintln!(); 661 return Ok(()); 662 } 663 664 eprintln!("⚠️ Documents differ (SHA256 hashes do not match)"); 665 eprintln!(); 666 667 #[cfg(feature = "similar")] 668 { 669 use similar::{ChangeTag, TextDiff}; 670 671 // Convert normalized JSON to pretty format for diff display 672 let local_json_pretty = json_to_pretty(&local_normalized)?; 673 let remote_json_pretty = json_to_pretty(&remote_normalized)?; 674 675 // Use similar to compute and display colored diff 676 let diff = TextDiff::from_lines(&local_json_pretty, &remote_json_pretty); 677 678 eprintln!("Diff (Local → Remote):"); 679 eprintln!("──────────────────────"); 680 681 for (idx, group) in diff.grouped_ops(3).iter().enumerate() { 682 if idx > 0 { 683 eprintln!("..."); 684 } 685 for op in group { 686 for change in diff.iter_changes(op) { 687 use super::utils::colors; 688 let (sign, style) = match change.tag() { 689 ChangeTag::Delete => ("-", colors::RED), 690 ChangeTag::Insert => ("+", colors::GREEN), 691 ChangeTag::Equal => (" ", colors::DIM), 692 }; 693 // Color the whole line 694 eprint!("{}{}{}{}", style, sign, change.value(), colors::RESET); 695 } 696 } 697 } 698 699 eprintln!(); 700 use super::utils::colors; 701 eprintln!( 702 "Legend: {}Local{} {}Remote{}", 703 colors::RED, 704 colors::RESET, 705 colors::GREEN, 706 colors::RESET 707 ); 708 eprintln!(); 709 } 710 711 #[cfg(not(feature = "similar"))] 712 { 713 eprintln!("💡 Tip: Enable 'similar' feature for colored diff output"); 714 eprintln!(); 715 } 716 717 Ok(()) 718} 719 720/// Normalize JSON string for comparison by parsing and re-serializing 721/// This ensures consistent representation for hash comparison 722fn normalize_json_for_comparison(json: &str) -> Result<String> { 723 // Parse JSON using sonic_rs (faster than serde_json) 724 let value: sonic_rs::Value = sonic_rs::from_str(json) 725 .map_err(|e| anyhow::anyhow!("Failed to parse JSON for normalization: {}", e))?; 726 727 // Re-serialize with consistent formatting (compact, no whitespace) 728 // This normalizes key ordering and whitespace differences 729 let normalized = sonic_rs::to_string(&value) 730 .map_err(|e| anyhow::anyhow!("Failed to serialize normalized JSON: {}", e))?; 731 732 Ok(normalized) 733} 734 735/// Convert JSON string to pretty-printed format for display 736fn json_to_pretty(json: &str) -> Result<String> { 737 let value: sonic_rs::Value = sonic_rs::from_str(json) 738 .map_err(|e| anyhow::anyhow!("Failed to parse JSON for pretty printing: {}", e))?; 739 740 let pretty = sonic_rs::to_string_pretty(&value) 741 .map_err(|e| anyhow::anyhow!("Failed to serialize pretty JSON: {}", e))?; 742 743 Ok(pretty) 744} 745 746fn calculate_shard_for_display(identifier: &str) -> u8 { 747 use fnv::FnvHasher; 748 use std::hash::Hasher; 749 750 let mut hasher = FnvHasher::default(); 751 hasher.write(identifier.as_bytes()); 752 let hash = hasher.finish() as u32; 753 (hash % 256) as u8 754} 755 756// DID HANDLE - Resolve handle to DID 757 758pub fn cmd_did_handle( 759 dir: PathBuf, 760 handle: String, 761 handle_resolver_url: Option<String>, 762) -> Result<()> { 763 use plcbundle::constants; 764 765 // Use default resolver if none provided 766 let resolver_url = 767 handle_resolver_url.or_else(|| Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string())); 768 769 // Initialize manager with handle resolver and preload mempool (for resolve command) 770 let options = plcbundle::ManagerOptions { 771 handle_resolver_url: resolver_url, 772 preload_mempool: true, 773 verbose: false, 774 }; 775 let manager = BundleManager::new(dir, options)?; 776 777 // Resolve handle to DID 778 let (did, resolve_time) = manager.resolve_handle_or_did(&handle)?; 779 780 if resolve_time > 0 { 781 println!("{}", did); 782 } else { 783 // If it was already a DID, just print it 784 println!("{}", did); 785 } 786 787 Ok(()) 788} 789 790// DID LOOKUP - Find all operations for a DID 791 792#[allow(clippy::too_many_arguments)] 793pub fn cmd_did_lookup( 794 dir: PathBuf, 795 input: String, 796 verbose: bool, 797 json: bool, 798 format: String, 799 no_header: bool, 800 separator: String, 801 reverse: bool, 802) -> Result<()> { 803 use plcbundle::constants; 804 use std::time::Instant; 805 806 // Use default resolver if none provided (same pattern as cmd_did_resolve) 807 let resolver_url = Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string()); 808 809 // Initialize manager with handle resolver and preload mempool (for resolve command) 810 let options = plcbundle::ManagerOptions { 811 handle_resolver_url: resolver_url, 812 preload_mempool: true, 813 verbose: false, 814 }; 815 let manager = BundleManager::new(dir, options)?; 816 817 // Resolve handle to DID if needed 818 let (did, handle_resolve_time) = manager.resolve_handle_or_did(&input)?; 819 820 if handle_resolve_time > 0 { 821 log::info!( 822 "Resolved handle: {} → {} (in {}ms)", 823 input, 824 did, 825 handle_resolve_time 826 ); 827 } else { 828 log::info!("Looking up DID: {}", did); 829 } 830 831 // Get DID index for shard calculation (only for PLC DIDs) 832 if let Some(identifier) = did.strip_prefix("did:plc:") { 833 let shard_num = calculate_shard_for_display(identifier); 834 log::debug!( 835 "DID {} -> identifier '{}' -> shard {:02x}", 836 did, 837 identifier, 838 shard_num 839 ); 840 } 841 842 let total_start = Instant::now(); 843 844 // Lookup operations with locations and stats (for verbose mode) 845 let result = manager.get_did_operations(&did, true, verbose)?; 846 let ops_with_loc = result.operations_with_locations.unwrap_or_default(); 847 let shard_stats = result.stats.unwrap_or_default(); 848 let shard_num = result.shard_num.unwrap_or(0); 849 let lookup_timings = result.lookup_timings.unwrap_or_default(); 850 let load_time = result.load_time.unwrap_or_default(); 851 852 let index_time = lookup_timings.extract_identifier 853 + lookup_timings.calculate_shard 854 + lookup_timings.load_shard 855 + lookup_timings.search; 856 let lookup_elapsed = index_time + load_time; 857 858 // Show shard stats if available 859 if verbose && shard_stats.total_entries > 0 { 860 log::debug!( 861 "Shard {:02x} loaded, size: {} bytes", 862 shard_num, 863 shard_stats.shard_size 864 ); 865 866 let reduction = if shard_stats.total_entries > 0 { 867 ((shard_stats.total_entries - shard_stats.prefix_narrowed_to) as f64 868 / shard_stats.total_entries as f64) 869 * 100.0 870 } else { 871 0.0 872 }; 873 874 log::debug!( 875 "Prefix index narrowed search: {} entries → {} entries ({:.1}% reduction)", 876 shard_stats.total_entries, 877 shard_stats.prefix_narrowed_to, 878 reduction 879 ); 880 log::debug!( 881 "Binary search found {} locations after {} attempts", 882 shard_stats.locations_found, 883 shard_stats.binary_search_attempts 884 ); 885 } 886 887 let total_elapsed = total_start.elapsed(); 888 889 // Show verbose timing breakdown 890 if verbose { 891 // Convert handle resolution time to Duration 892 let handle_resolve_duration = std::time::Duration::from_millis(handle_resolve_time); 893 894 if handle_resolve_time > 0 { 895 log::info!("Handle resolution: {:?}", handle_resolve_duration); 896 } 897 898 // Show detailed index lookup timings 899 log::info!("Index Lookup Breakdown:"); 900 log::info!(" Extract ID: {:?}", lookup_timings.extract_identifier); 901 log::info!(" Calc shard: {:?}", lookup_timings.calculate_shard); 902 log::info!( 903 " Load shard: {:?} ({})", 904 lookup_timings.load_shard, 905 if lookup_timings.cache_hit { 906 "cache hit" 907 } else { 908 "cache miss" 909 } 910 ); 911 912 // Search breakdown 913 log::info!(" Search:"); 914 if let Some(ref base_time) = lookup_timings.base_search_time { 915 log::info!(" Base shard: {:?}", base_time); 916 } 917 if !lookup_timings.delta_segment_times.is_empty() { 918 let total_delta_time: std::time::Duration = lookup_timings 919 .delta_segment_times 920 .iter() 921 .map(|(_, time)| *time) 922 .sum(); 923 log::info!( 924 " Delta segs: {:?} ({} segment{})", 925 total_delta_time, 926 lookup_timings.delta_segment_times.len(), 927 if lookup_timings.delta_segment_times.len() == 1 { 928 "" 929 } else { 930 "s" 931 } 932 ); 933 934 // Show individual delta segments if there are multiple 935 if lookup_timings.delta_segment_times.len() > 1 { 936 for (seg_name, seg_time) in &lookup_timings.delta_segment_times { 937 log::info!(" - {}: {:?}", seg_name, seg_time); 938 } 939 } 940 } 941 if lookup_timings.merge_time.as_nanos() > 0 { 942 log::info!(" Merge/sort: {:?}", lookup_timings.merge_time); 943 } 944 log::info!(" Search total: {:?}", lookup_timings.search); 945 log::info!(" Index total: {:?}", index_time); 946 log::info!( 947 " Load operations: {:?} ({} operations)", 948 load_time, 949 ops_with_loc.len() 950 ); 951 // Calculate true total including handle resolution 952 let true_total = handle_resolve_duration + total_elapsed; 953 log::info!( 954 "Total: {:?} (handle: {:?} + lookup: {:?})", 955 true_total, 956 handle_resolve_duration, 957 total_elapsed 958 ); 959 log::info!(""); 960 } 961 962 // Separate bundled and mempool operations (mempool ops have bundle=0) 963 let mut bundled_ops = Vec::new(); 964 let mut mempool_ops = Vec::new(); 965 for owl in ops_with_loc.iter() { 966 if owl.bundle != 0 { 967 bundled_ops.push(owl.clone()); 968 } else { 969 mempool_ops.push(&owl.operation); 970 } 971 } 972 973 if bundled_ops.is_empty() && mempool_ops.is_empty() { 974 if json { 975 println!("{{\"found\": false, \"operations\": []}}"); 976 } else { 977 println!("DID not found (searched in {:?})", total_elapsed); 978 } 979 return Ok(()); 980 } 981 982 if json { 983 return output_lookup_json( 984 &did, 985 &bundled_ops, 986 &mempool_ops, 987 total_elapsed, 988 lookup_elapsed, 989 reverse, 990 ); 991 } 992 993 display_lookup_results( 994 &did, 995 &bundled_ops, 996 &mempool_ops, 997 total_elapsed, 998 lookup_elapsed, 999 verbose, 1000 &format, 1001 no_header, 1002 &separator, 1003 reverse, 1004 ) 1005} 1006 1007fn output_lookup_json( 1008 did: &str, 1009 bundled_ops: &[plcbundle::OperationWithLocation], 1010 mempool_ops: &[&plcbundle::Operation], 1011 total_elapsed: std::time::Duration, 1012 lookup_elapsed: std::time::Duration, 1013 reverse: bool, 1014) -> Result<()> { 1015 use serde_json::json; 1016 1017 let mut bundled = Vec::new(); 1018 for owl in bundled_ops { 1019 bundled.push(json!({ 1020 "bundle": owl.bundle, 1021 "position": owl.position, 1022 "cid": owl.operation.cid, 1023 "nullified": owl.nullified, 1024 "created_at": owl.operation.created_at, 1025 })); 1026 } 1027 1028 let mut mempool = Vec::new(); 1029 for op in mempool_ops { 1030 mempool.push(json!({ 1031 "cid": op.cid, 1032 "nullified": op.nullified, 1033 "created_at": op.created_at, 1034 })); 1035 } 1036 1037 if reverse { 1038 bundled.reverse(); 1039 mempool.reverse(); 1040 } 1041 1042 let output = json!({ 1043 "found": true, 1044 "did": did, 1045 "timing": { 1046 "total_ms": total_elapsed.as_millis(), 1047 "lookup_ms": lookup_elapsed.as_millis(), 1048 }, 1049 "bundled": bundled, 1050 "mempool": mempool, 1051 }); 1052 1053 println!("{}", sonic_rs::to_string_pretty(&output)?); 1054 Ok(()) 1055} 1056 1057#[allow(clippy::too_many_arguments)] 1058fn display_lookup_results( 1059 did: &str, 1060 bundled_ops: &[plcbundle::OperationWithLocation], 1061 mempool_ops: &[&plcbundle::Operation], 1062 _total_elapsed: std::time::Duration, 1063 _lookup_elapsed: std::time::Duration, 1064 verbose: bool, 1065 format: &str, 1066 no_header: bool, 1067 separator: &str, 1068 reverse: bool, 1069) -> Result<()> { 1070 let nullified_count = bundled_ops.iter().filter(|owl| owl.nullified).count(); 1071 let total_ops = bundled_ops.len() + mempool_ops.len(); 1072 let active_ops = bundled_ops.len() - nullified_count + mempool_ops.len(); 1073 1074 // Parse format string 1075 let fields = parse_format_string(format); 1076 1077 // Print summary header (unless no_header is set, but we always show the DID summary) 1078 if !no_header { 1079 println!("DID: {} ({} ops, {} active)", did, total_ops, active_ops); 1080 } 1081 1082 if fields.is_empty() { 1083 return Ok(()); 1084 } 1085 1086 // Calculate column widths for alignment 1087 let mut column_widths = vec![0; fields.len()]; 1088 1089 // Check header widths 1090 for (i, field) in fields.iter().enumerate() { 1091 let header = get_lookup_field_header(field); 1092 column_widths[i] = column_widths[i].max(header.len()); 1093 } 1094 1095 // Check data widths 1096 for owl in bundled_ops.iter() { 1097 for (i, field) in fields.iter().enumerate() { 1098 let value = get_lookup_field_value(owl, None, field); 1099 column_widths[i] = column_widths[i].max(value.len()); 1100 } 1101 } 1102 1103 for op in mempool_ops.iter() { 1104 for (i, field) in fields.iter().enumerate() { 1105 let value = get_lookup_field_value_mempool(op, field); 1106 column_widths[i] = column_widths[i].max(value.len()); 1107 } 1108 } 1109 1110 // Print column header (unless disabled) 1111 if !no_header { 1112 let headers: Vec<String> = fields 1113 .iter() 1114 .enumerate() 1115 .map(|(i, f)| { 1116 let header = get_lookup_field_header(f); 1117 if separator == "\t" { 1118 // For tabs, pad with spaces for alignment 1119 format!("{:<width$}", header, width = column_widths[i]) 1120 } else { 1121 header 1122 } 1123 }) 1124 .collect(); 1125 println!("{}", headers.join(separator)); 1126 } 1127 1128 // Show operations - one per line 1129 let bundled_iter: Box<dyn Iterator<Item = &plcbundle::OperationWithLocation>> = if reverse { 1130 Box::new(bundled_ops.iter().rev()) 1131 } else { 1132 Box::new(bundled_ops.iter()) 1133 }; 1134 1135 for owl in bundled_iter { 1136 let values: Vec<String> = fields 1137 .iter() 1138 .enumerate() 1139 .map(|(i, f)| { 1140 let value = get_lookup_field_value(owl, None, f); 1141 if separator == "\t" { 1142 // For tabs, pad with spaces for alignment 1143 format!("{:<width$}", value, width = column_widths[i]) 1144 } else { 1145 value 1146 } 1147 }) 1148 .collect(); 1149 println!("{}", values.join(separator)); 1150 1151 if verbose && !owl.nullified { 1152 let op_val = &owl.operation.operation; 1153 if let Some(op_type) = op_val.get("type").and_then(|v| v.as_str()) { 1154 eprintln!(" type: {}", op_type); 1155 } 1156 if let Some(handle) = op_val.get("handle").and_then(|v| v.as_str()) { 1157 eprintln!(" handle: {}", handle); 1158 } else if let Some(aka) = op_val.get("alsoKnownAs").and_then(|v| v.as_array()) 1159 && let Some(aka_str) = aka.first().and_then(|v| v.as_str()) 1160 { 1161 let handle = aka_str.strip_prefix("at://").unwrap_or(aka_str); 1162 eprintln!(" handle: {}", handle); 1163 } 1164 } 1165 } 1166 1167 // Show mempool operations 1168 let mempool_iter: Box<dyn Iterator<Item = &&plcbundle::Operation>> = if reverse { 1169 Box::new(mempool_ops.iter().rev()) 1170 } else { 1171 Box::new(mempool_ops.iter()) 1172 }; 1173 1174 for op in mempool_iter { 1175 let values: Vec<String> = fields 1176 .iter() 1177 .enumerate() 1178 .map(|(i, f)| { 1179 let value = get_lookup_field_value_mempool(op, f); 1180 if separator == "\t" { 1181 // For tabs, pad with spaces for alignment 1182 format!("{:<width$}", value, width = column_widths[i]) 1183 } else { 1184 value 1185 } 1186 }) 1187 .collect(); 1188 println!("{}", values.join(separator)); 1189 } 1190 1191 Ok(()) 1192} 1193 1194fn parse_format_string(format: &str) -> Vec<String> { 1195 format 1196 .split(',') 1197 .map(|s| s.trim().to_string()) 1198 .filter(|s| !s.is_empty()) 1199 .collect() 1200} 1201 1202fn get_lookup_field_header(field: &str) -> String { 1203 match field { 1204 "bundle" => "bundle", 1205 "position" | "pos" => "position", 1206 "global" | "global_pos" => "global", 1207 "status" => "status", 1208 "cid" => "cid", 1209 "created_at" | "created" | "date" | "time" => "created_at", 1210 "nullified" => "nullified", 1211 "date_short" => "date", 1212 "timestamp" | "unix" => "timestamp", 1213 _ => field, 1214 } 1215 .to_string() 1216} 1217 1218fn get_lookup_field_value( 1219 owl: &plcbundle::OperationWithLocation, 1220 _mempool: Option<&plcbundle::Operation>, 1221 field: &str, 1222) -> String { 1223 match field { 1224 "bundle" => format!("{}", owl.bundle), 1225 "position" | "pos" => format!("{:04}", owl.position), 1226 "global" | "global_pos" => { 1227 let global_pos = 1228 plcbundle::constants::bundle_position_to_global(owl.bundle, owl.position); 1229 format!("{}", global_pos) 1230 } 1231 "status" => { 1232 if owl.nullified { 1233 "".to_string() 1234 } else { 1235 "".to_string() 1236 } 1237 } 1238 "cid" => owl.operation.cid.clone().unwrap_or_default(), 1239 "created_at" | "created" | "date" | "time" => owl.operation.created_at.clone(), 1240 "nullified" => { 1241 if owl.nullified { 1242 "true".to_string() 1243 } else { 1244 "false".to_string() 1245 } 1246 } 1247 "date_short" => { 1248 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&owl.operation.created_at) { 1249 dt.format("%Y-%m-%d").to_string() 1250 } else { 1251 owl.operation.created_at.clone() 1252 } 1253 } 1254 "timestamp" | "unix" => { 1255 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&owl.operation.created_at) { 1256 format!("{}", dt.timestamp()) 1257 } else { 1258 "0".to_string() 1259 } 1260 } 1261 _ => String::new(), 1262 } 1263} 1264 1265fn get_lookup_field_value_mempool(op: &plcbundle::Operation, field: &str) -> String { 1266 match field { 1267 "bundle" => "mempool".to_string(), 1268 "position" | "pos" => "".to_string(), 1269 "global" | "global_pos" => "".to_string(), 1270 "status" => "".to_string(), 1271 "cid" => op.cid.clone().unwrap_or_default(), 1272 "created_at" | "created" | "date" | "time" => op.created_at.clone(), 1273 "nullified" => "false".to_string(), 1274 "date_short" => { 1275 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&op.created_at) { 1276 dt.format("%Y-%m-%d").to_string() 1277 } else { 1278 op.created_at.clone() 1279 } 1280 } 1281 "timestamp" | "unix" => { 1282 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&op.created_at) { 1283 format!("{}", dt.timestamp()) 1284 } else { 1285 "0".to_string() 1286 } 1287 } 1288 _ => String::new(), 1289 } 1290} 1291 1292// DID BATCH - Process multiple DIDs (TODO) 1293 1294pub fn cmd_did_batch( 1295 _dir: PathBuf, 1296 _action: String, 1297 _threads: usize, 1298 _output: Option<PathBuf>, 1299 _from_stdin: bool, 1300) -> Result<()> { 1301 log::error!("`did batch` not yet implemented"); 1302 Ok(()) 1303} 1304 1305// DID VALIDATE - Validate DID audit log from plc.directory 1306// 1307// This implementation is based on the atproto-plc library examples: 1308// https://docs.rs/atproto-plc/0.2.0/atproto_plc/index.html 1309// Library author: ngerakines 1310// License: MIT OR Apache-2.0 1311 1312use atproto_plc::{Operation, OperationChainValidator, PlcState, VerifyingKey}; 1313use serde::Deserialize; 1314use std::collections::HashMap; 1315 1316/// Audit log response from plc.directory 1317#[derive(Debug, Deserialize, Clone)] 1318struct AuditLogEntry { 1319 /// The DID this operation is for 1320 #[allow(dead_code)] 1321 did: String, 1322 1323 /// The operation itself 1324 operation: Operation, 1325 1326 /// CID of this operation 1327 cid: String, 1328 1329 /// Timestamp when this operation was created 1330 #[serde(rename = "createdAt")] 1331 created_at: String, 1332 1333 /// Nullified flag (if this operation was invalidated) 1334 #[serde(default)] 1335 nullified: bool, 1336} 1337 1338/// Represents a fork point in the operation chain 1339#[derive(Debug, Clone)] 1340struct ForkPoint { 1341 /// The prev CID that all operations in this fork reference 1342 prev_cid: String, 1343 1344 /// Competing operations at this fork 1345 operations: Vec<ForkOperation>, 1346 1347 /// The winning operation (after fork resolution) 1348 #[allow(dead_code)] 1349 winner_cid: String, 1350} 1351 1352/// An operation that's part of a fork 1353#[derive(Debug, Clone)] 1354struct ForkOperation { 1355 cid: String, 1356 #[allow(dead_code)] 1357 operation: Operation, 1358 timestamp: chrono::DateTime<chrono::Utc>, 1359 signing_key_index: Option<usize>, 1360 signing_key: Option<String>, 1361 is_winner: bool, 1362 rejection_reason: Option<String>, 1363} 1364 1365pub fn cmd_did_validate( 1366 dir: PathBuf, 1367 did_input: String, 1368 verbose: bool, 1369 quiet: bool, 1370 fork_viz: bool, 1371) -> Result<()> { 1372 use plcbundle::constants; 1373 1374 // Initialize manager 1375 let resolver_url = Some(constants::DEFAULT_HANDLE_RESOLVER_URL.to_string()); 1376 let options = plcbundle::ManagerOptions { 1377 handle_resolver_url: resolver_url, 1378 preload_mempool: false, 1379 verbose: false, 1380 }; 1381 let manager = BundleManager::new(dir, options)?; 1382 1383 // Resolve handle to DID if needed (same pattern as other did commands) 1384 let (did_str, handle_resolve_time) = manager.resolve_handle_or_did(&did_input)?; 1385 1386 if handle_resolve_time > 0 { 1387 log::info!( 1388 "Resolved handle: {} → {} (in {}ms)", 1389 did_input, 1390 did_str, 1391 handle_resolve_time 1392 ); 1393 } else { 1394 log::info!("Validating DID: {}", did_str); 1395 } 1396 1397 // Validate DID format for atproto_plc library (must be did:plc) 1398 if !did_str.starts_with("did:plc:") { 1399 eprintln!("❌ Error: Validation only supports did:plc identifiers"); 1400 eprintln!(" Got: {}", did_str); 1401 return Err(anyhow::anyhow!("Only did:plc identifiers can be validated")); 1402 } 1403 1404 if !quiet { 1405 println!("🔍 Fetching audit log for: {}", did_str); 1406 println!(" Source: local bundles"); 1407 println!(); 1408 } 1409 1410 // Fetch operations from local bundles and mempool 1411 let result = manager.get_did_operations(&did_str, true, false)?; 1412 let ops_with_loc = result.operations_with_locations.unwrap_or_default(); 1413 1414 if ops_with_loc.is_empty() { 1415 eprintln!("❌ Error: No operations found for DID: {}", did_str); 1416 return Err(anyhow::anyhow!("No operations found")); 1417 } 1418 1419 // Convert to audit log format 1420 let audit_log = convert_to_audit_log(ops_with_loc)?; 1421 1422 if audit_log.is_empty() { 1423 eprintln!("❌ Error: No operations found in audit log"); 1424 return Err(anyhow::anyhow!("No operations found")); 1425 } 1426 1427 if !quiet { 1428 println!("📊 Audit Log Summary:"); 1429 println!(" Total operations: {}", audit_log.len()); 1430 println!(" Genesis operation: {}", audit_log[0].cid); 1431 println!(" Latest operation: {}", audit_log.last().unwrap().cid); 1432 println!(); 1433 } 1434 1435 // If fork visualization is requested, show that instead 1436 if fork_viz { 1437 return visualize_forks(&audit_log, &did_str, verbose); 1438 } 1439 1440 // Display operations if verbose 1441 if verbose { 1442 println!("📋 Operations:"); 1443 for (i, entry) in audit_log.iter().enumerate() { 1444 let status = if entry.nullified { 1445 "❌ NULLIFIED" 1446 } else { 1447 "" 1448 }; 1449 println!(" [{}] {} {} - {}", i, status, entry.cid, entry.created_at); 1450 if entry.operation.is_genesis() { 1451 println!(" Type: Genesis (creates the DID)"); 1452 } else { 1453 println!(" Type: Update"); 1454 } 1455 if let Some(prev) = entry.operation.prev() { 1456 println!(" Previous: {}", prev); 1457 } 1458 } 1459 println!(); 1460 } 1461 1462 // Detect forks and build canonical chain 1463 if !quiet { 1464 println!("🔐 Analyzing operation chain..."); 1465 println!(); 1466 } 1467 1468 // Detect fork points and nullified operations 1469 let has_forks = detect_forks(&audit_log); 1470 let has_nullified = audit_log.iter().any(|e| e.nullified); 1471 1472 if has_forks || has_nullified { 1473 if !quiet { 1474 if has_forks { 1475 println!("⚠️ Fork detected - multiple operations reference the same prev CID"); 1476 } 1477 if has_nullified { 1478 println!("⚠️ Nullified operations detected - will validate canonical chain only"); 1479 } 1480 println!(); 1481 } 1482 1483 // Use fork resolution to build canonical chain 1484 if verbose { 1485 println!("Step 1: Fork Resolution & Canonical Chain Building"); 1486 println!("==================================================="); 1487 } 1488 1489 // Build operations and timestamps for fork resolution 1490 let operations: Vec<_> = audit_log.iter().map(|e| e.operation.clone()).collect(); 1491 let timestamps: Vec<_> = audit_log 1492 .iter() 1493 .map(|e| { 1494 e.created_at 1495 .parse::<chrono::DateTime<chrono::Utc>>() 1496 .unwrap_or_else(|_| chrono::Utc::now()) 1497 }) 1498 .collect(); 1499 1500 // Resolve forks and get canonical chain 1501 match OperationChainValidator::validate_chain_with_forks(&operations, &timestamps) { 1502 Ok(final_state) => { 1503 if verbose { 1504 println!(" ✅ Fork resolution complete"); 1505 println!(" ✅ Canonical chain validated successfully"); 1506 println!(); 1507 1508 // Show which operations are in the canonical chain 1509 println!("Canonical Chain Operations:"); 1510 println!("==========================="); 1511 let canonical_indices = build_canonical_chain_indices(&audit_log); 1512 for idx in &canonical_indices { 1513 let entry = &audit_log[*idx]; 1514 println!(" [{}] ✅ {} - {}", idx, entry.cid, entry.created_at); 1515 } 1516 println!(); 1517 1518 if has_nullified { 1519 println!("Nullified/Rejected Operations:"); 1520 println!("=============================="); 1521 for (i, entry) in audit_log.iter().enumerate() { 1522 if entry.nullified && !canonical_indices.contains(&i) { 1523 println!( 1524 " [{}] ❌ {} - {} (nullified)", 1525 i, entry.cid, entry.created_at 1526 ); 1527 if let Some(prev) = entry.operation.prev() { 1528 println!(" Referenced: {}", prev); 1529 } 1530 } 1531 } 1532 println!(); 1533 } 1534 } 1535 1536 // Display final state 1537 display_final_state(&final_state, quiet); 1538 return Ok(()); 1539 } 1540 Err(e) => { 1541 eprintln!(); 1542 eprintln!("❌ Validation failed: {}", e); 1543 return Err(anyhow::anyhow!("Validation failed: {}", e)); 1544 } 1545 } 1546 } 1547 1548 // Simple linear chain validation (no forks or nullified operations) 1549 if verbose { 1550 println!("Step 1: Linear Chain Validation"); 1551 println!("================================"); 1552 } 1553 1554 for i in 1..audit_log.len() { 1555 let prev_cid = audit_log[i - 1].cid.clone(); 1556 let expected_prev = audit_log[i].operation.prev(); 1557 1558 if verbose { 1559 println!(" [{}] Checking prev reference...", i); 1560 println!(" Expected: {}", prev_cid); 1561 } 1562 1563 if let Some(actual_prev) = expected_prev { 1564 if verbose { 1565 println!(" Actual: {}", actual_prev); 1566 } 1567 1568 if actual_prev != prev_cid { 1569 eprintln!(); 1570 eprintln!( 1571 "❌ Validation failed: Chain linkage broken at operation {}", 1572 i 1573 ); 1574 eprintln!(" Expected prev: {}", prev_cid); 1575 eprintln!(" Actual prev: {}", actual_prev); 1576 return Err(anyhow::anyhow!("Chain linkage broken")); 1577 } 1578 1579 if verbose { 1580 println!(" ✅ Match - chain link valid"); 1581 } 1582 } else if i > 0 { 1583 eprintln!(); 1584 eprintln!( 1585 "❌ Validation failed: Non-genesis operation {} missing prev field", 1586 i 1587 ); 1588 return Err(anyhow::anyhow!("Missing prev field")); 1589 } 1590 } 1591 1592 if verbose { 1593 println!(); 1594 println!("✅ Chain linkage validation complete"); 1595 println!(); 1596 } 1597 1598 // Step 2: Validate cryptographic signatures 1599 if verbose { 1600 println!("Step 2: Cryptographic Signature Validation"); 1601 println!("=========================================="); 1602 } 1603 1604 let mut current_rotation_keys: Vec<String> = Vec::new(); 1605 1606 for (i, entry) in audit_log.iter().enumerate() { 1607 if entry.nullified { 1608 if verbose { 1609 println!(" [{}] ⊘ Skipped (nullified)", i); 1610 } 1611 continue; 1612 } 1613 1614 // For genesis operation, we can't validate signature without rotation keys 1615 // But we can extract them and validate subsequent operations 1616 if i == 0 { 1617 if verbose { 1618 println!(" [{}] Genesis operation - extracting rotation keys", i); 1619 } 1620 if let Some(rotation_keys) = entry.operation.rotation_keys() { 1621 current_rotation_keys = rotation_keys.to_vec(); 1622 if verbose { 1623 println!(" Rotation keys: {}", rotation_keys.len()); 1624 for (j, key) in rotation_keys.iter().enumerate() { 1625 println!(" [{}] {}", j, key); 1626 } 1627 println!( 1628 " ⚠️ Genesis signature cannot be verified (bootstrapping trust)" 1629 ); 1630 } 1631 } 1632 continue; 1633 } 1634 1635 if verbose { 1636 println!(" [{}] Validating signature...", i); 1637 println!(" CID: {}", entry.cid); 1638 println!(" Signature: {}", entry.operation.signature()); 1639 } 1640 1641 // Validate signature using current rotation keys 1642 if !current_rotation_keys.is_empty() { 1643 if verbose { 1644 println!( 1645 " Available rotation keys: {}", 1646 current_rotation_keys.len() 1647 ); 1648 for (j, key) in current_rotation_keys.iter().enumerate() { 1649 println!(" [{}] {}", j, key); 1650 } 1651 } 1652 1653 let verifying_keys: Vec<VerifyingKey> = current_rotation_keys 1654 .iter() 1655 .filter_map(|k| VerifyingKey::from_did_key(k).ok()) 1656 .collect(); 1657 1658 if verbose { 1659 println!( 1660 " Parsed verifying keys: {}/{}", 1661 verifying_keys.len(), 1662 current_rotation_keys.len() 1663 ); 1664 } 1665 1666 // Try to verify with each key and track which one worked 1667 let mut verified = false; 1668 let mut verification_key_index = None; 1669 1670 for (j, key) in verifying_keys.iter().enumerate() { 1671 if entry.operation.verify(&[*key]).is_ok() { 1672 verified = true; 1673 verification_key_index = Some(j); 1674 break; 1675 } 1676 } 1677 1678 if !verified { 1679 // Final attempt with all keys (for comprehensive error) 1680 if let Err(e) = entry.operation.verify(&verifying_keys) { 1681 eprintln!(); 1682 eprintln!("❌ Validation failed: Invalid signature at operation {}", i); 1683 eprintln!(" Error: {}", e); 1684 eprintln!(" CID: {}", entry.cid); 1685 eprintln!( 1686 " Tried {} rotation keys, none verified the signature", 1687 verifying_keys.len() 1688 ); 1689 return Err(anyhow::anyhow!("Invalid signature")); 1690 } 1691 } 1692 1693 if verbose { 1694 if let Some(key_idx) = verification_key_index { 1695 println!( 1696 " ✅ Signature verified with rotation key [{}]", 1697 key_idx 1698 ); 1699 println!(" {}", current_rotation_keys[key_idx]); 1700 } else { 1701 println!(" ✅ Signature verified"); 1702 } 1703 } 1704 } 1705 1706 // Update rotation keys if this operation changes them 1707 if let Some(new_rotation_keys) = entry.operation.rotation_keys() 1708 && new_rotation_keys != current_rotation_keys 1709 { 1710 if verbose { 1711 println!(" 🔄 Rotation keys updated by this operation"); 1712 println!(" Old keys: {}", current_rotation_keys.len()); 1713 println!(" New keys: {}", new_rotation_keys.len()); 1714 for (j, key) in new_rotation_keys.iter().enumerate() { 1715 println!(" [{}] {}", j, key); 1716 } 1717 } 1718 current_rotation_keys = new_rotation_keys.to_vec(); 1719 } 1720 } 1721 1722 if verbose { 1723 println!(); 1724 println!("✅ Cryptographic signature validation complete"); 1725 println!(); 1726 } 1727 1728 // Build final state 1729 let final_entry = audit_log.last().unwrap(); 1730 if let Some(_rotation_keys) = final_entry.operation.rotation_keys() { 1731 let final_state = match &final_entry.operation { 1732 Operation::PlcOperation { 1733 rotation_keys, 1734 verification_methods, 1735 also_known_as, 1736 services, 1737 .. 1738 } => PlcState { 1739 rotation_keys: rotation_keys.clone(), 1740 verification_methods: verification_methods.clone(), 1741 also_known_as: also_known_as.clone(), 1742 services: services.clone(), 1743 }, 1744 _ => PlcState::new(), 1745 }; 1746 1747 display_final_state(&final_state, quiet); 1748 } else { 1749 eprintln!("❌ Error: Could not extract final state"); 1750 return Err(anyhow::anyhow!("Could not extract final state")); 1751 } 1752 1753 Ok(()) 1754} 1755 1756/// Detect if there are fork points in the audit log 1757fn detect_forks(audit_log: &[AuditLogEntry]) -> bool { 1758 let mut prev_counts: HashMap<String, usize> = HashMap::new(); 1759 for entry in audit_log { 1760 if let Some(prev) = entry.operation.prev() { 1761 *prev_counts.entry(prev.to_string()).or_insert(0) += 1; 1762 } 1763 } 1764 // If any prev CID is referenced by more than one operation, there's a fork 1765 prev_counts.values().any(|&count| count > 1) 1766} 1767 1768/// Build a list of indices that form the canonical chain 1769fn build_canonical_chain_indices(audit_log: &[AuditLogEntry]) -> Vec<usize> { 1770 // Build a map of prev CID to operations 1771 let mut prev_to_indices: HashMap<String, Vec<usize>> = HashMap::new(); 1772 for (i, entry) in audit_log.iter().enumerate() { 1773 if let Some(prev) = entry.operation.prev() { 1774 prev_to_indices.entry(prev.to_string()).or_default().push(i); 1775 } 1776 } 1777 1778 // Start from genesis and follow the canonical chain 1779 let mut canonical = Vec::new(); 1780 1781 // Find genesis (first operation) 1782 let genesis = match audit_log.first() { 1783 Some(g) => g, 1784 None => return canonical, 1785 }; 1786 1787 canonical.push(0); 1788 let mut current_cid = genesis.cid.clone(); 1789 1790 // Follow the chain, preferring non-nullified operations 1791 while let Some(indices) = prev_to_indices.get(&current_cid) { 1792 // Find the first non-nullified operation 1793 if let Some(&next_idx) = indices.iter().find(|&&idx| !audit_log[idx].nullified) { 1794 canonical.push(next_idx); 1795 current_cid = audit_log[next_idx].cid.clone(); 1796 } else { 1797 // All operations at this point are nullified - try to find any operation 1798 if let Some(&next_idx) = indices.first() { 1799 canonical.push(next_idx); 1800 current_cid = audit_log[next_idx].cid.clone(); 1801 } else { 1802 break; 1803 } 1804 } 1805 } 1806 1807 canonical 1808} 1809 1810/// Display the final state after validation 1811fn display_final_state(final_state: &PlcState, quiet: bool) { 1812 if quiet { 1813 println!("✅ VALID"); 1814 } else { 1815 println!("✅ Validation successful!"); 1816 println!(); 1817 1818 println!("📄 Final DID State:"); 1819 println!(" Rotation keys: {}", final_state.rotation_keys.len()); 1820 for (i, key) in final_state.rotation_keys.iter().enumerate() { 1821 println!(" [{}] {}", i, key); 1822 } 1823 println!(); 1824 1825 println!( 1826 " Verification methods: {}", 1827 final_state.verification_methods.len() 1828 ); 1829 for (name, key) in &final_state.verification_methods { 1830 println!(" {}: {}", name, key); 1831 } 1832 println!(); 1833 1834 if !final_state.also_known_as.is_empty() { 1835 println!(" Also known as: {}", final_state.also_known_as.len()); 1836 for uri in &final_state.also_known_as { 1837 println!(" - {}", uri); 1838 } 1839 println!(); 1840 } 1841 1842 if !final_state.services.is_empty() { 1843 println!(" Services: {}", final_state.services.len()); 1844 for (name, service) in &final_state.services { 1845 println!( 1846 " {}: {} ({})", 1847 name, service.endpoint, service.service_type 1848 ); 1849 } 1850 } 1851 } 1852} 1853 1854/// Convert local bundle operations to audit log format 1855fn convert_to_audit_log( 1856 ops_with_loc: Vec<plcbundle::OperationWithLocation>, 1857) -> Result<Vec<AuditLogEntry>> { 1858 let mut audit_log = Vec::new(); 1859 1860 for owl in ops_with_loc { 1861 // Extract the operation JSON and convert to atproto_plc::Operation 1862 // Note: atproto_plc uses serde, so we use serde_json here (not parsing from bundle) 1863 let operation_json = serde_json::to_value(&owl.operation.operation)?; 1864 let operation: Operation = serde_json::from_value(operation_json) 1865 .map_err(|e| anyhow::anyhow!("Failed to parse operation: {}", e))?; 1866 1867 // Get CID from bundle operation (should always be present) 1868 let cid = owl.operation.cid.clone().unwrap_or_else(|| { 1869 // Fallback: this shouldn't happen in real data, but provide a placeholder 1870 format!("bundle_{}_pos_{}", owl.bundle, owl.position) 1871 }); 1872 1873 audit_log.push(AuditLogEntry { 1874 did: owl.operation.did.clone(), 1875 operation, 1876 cid, 1877 created_at: owl.operation.created_at.clone(), 1878 nullified: owl.nullified || owl.operation.nullified, 1879 }); 1880 } 1881 1882 Ok(audit_log) 1883} 1884 1885/// Visualize forks in the audit log 1886fn visualize_forks(audit_log: &[AuditLogEntry], did_str: &str, verbose: bool) -> Result<()> { 1887 println!("🔍 Analyzing forks in: {}", did_str); 1888 println!(" Source: local bundles"); 1889 println!(); 1890 1891 println!("📊 Audit log contains {} operations", audit_log.len()); 1892 1893 // Detect forks 1894 let forks = detect_forks_detailed(audit_log, verbose); 1895 1896 if forks.is_empty() { 1897 println!("\n✅ No forks detected - this is a linear operation chain"); 1898 println!(" All operations form a single canonical path from genesis to tip."); 1899 1900 if verbose { 1901 println!("\n📋 Linear chain visualization:"); 1902 visualize_linear_chain(audit_log); 1903 } 1904 1905 return Ok(()); 1906 } 1907 1908 println!("⚠️ Detected {} fork point(s)", forks.len()); 1909 println!(); 1910 1911 visualize_tree(audit_log, &forks, verbose); 1912 1913 Ok(()) 1914} 1915 1916/// Detect fork points in the audit log with detailed information 1917fn detect_forks_detailed(audit_log: &[AuditLogEntry], verbose: bool) -> Vec<ForkPoint> { 1918 let mut prev_to_operations: HashMap<String, Vec<AuditLogEntry>> = HashMap::new(); 1919 1920 // Group operations by their prev CID 1921 for entry in audit_log { 1922 if let Some(prev) = entry.operation.prev() { 1923 prev_to_operations 1924 .entry(prev.to_string()) 1925 .or_default() 1926 .push(entry.clone()); 1927 } 1928 } 1929 1930 // Build operation map for state reconstruction 1931 let operation_map: HashMap<String, AuditLogEntry> = audit_log 1932 .iter() 1933 .map(|e| (e.cid.clone(), e.clone())) 1934 .collect(); 1935 1936 let mut forks = Vec::new(); 1937 1938 // Find fork points (where multiple operations reference the same prev) 1939 for (prev_cid, operations) in prev_to_operations { 1940 if operations.len() > 1 { 1941 if verbose { 1942 println!("🔀 Fork detected at {}", truncate_cid(&prev_cid)); 1943 println!(" {} competing operations", operations.len()); 1944 } 1945 1946 // Get the state at the prev operation to determine rotation keys 1947 let state = if let Some(prev_entry) = operation_map.get(&prev_cid) { 1948 get_state_from_operation(&prev_entry.operation) 1949 } else { 1950 // This shouldn't happen in a valid chain 1951 continue; 1952 }; 1953 1954 // Analyze each operation in the fork 1955 let mut fork_ops = Vec::new(); 1956 for entry in &operations { 1957 let timestamp = parse_timestamp(&entry.created_at); 1958 1959 // Determine which rotation key signed this operation 1960 let (signing_key_index, signing_key) = if !state.rotation_keys.is_empty() { 1961 find_signing_key(&entry.operation, &state.rotation_keys) 1962 } else { 1963 (None, None) 1964 }; 1965 1966 fork_ops.push(ForkOperation { 1967 cid: entry.cid.clone(), 1968 operation: entry.operation.clone(), 1969 timestamp, 1970 signing_key_index, 1971 signing_key, 1972 is_winner: false, 1973 rejection_reason: None, 1974 }); 1975 } 1976 1977 // Resolve the fork to determine winner 1978 let winner_cid = resolve_fork(&mut fork_ops); 1979 1980 forks.push(ForkPoint { 1981 prev_cid, 1982 operations: fork_ops, 1983 winner_cid, 1984 }); 1985 } 1986 } 1987 1988 // Sort forks chronologically 1989 forks.sort_by_key(|f| { 1990 f.operations 1991 .iter() 1992 .map(|op| op.timestamp) 1993 .min() 1994 .unwrap_or_else(chrono::Utc::now) 1995 }); 1996 1997 forks 1998} 1999 2000/// Resolve a fork point and mark the winner 2001fn resolve_fork(fork_ops: &mut [ForkOperation]) -> String { 2002 // Sort by timestamp (chronological order) 2003 fork_ops.sort_by_key(|op| op.timestamp); 2004 2005 // First-received is the default winner 2006 let mut winner_idx = 0; 2007 fork_ops[0].is_winner = true; 2008 2009 // Check if any later operation can invalidate based on priority 2010 for i in 1..fork_ops.len() { 2011 let competing_key_idx = fork_ops[i].signing_key_index; 2012 let winner_key_idx = fork_ops[winner_idx].signing_key_index; 2013 2014 match (competing_key_idx, winner_key_idx) { 2015 (Some(competing_idx), Some(winner_idx_val)) => { 2016 if competing_idx < winner_idx_val { 2017 // Higher priority (lower index) 2018 let time_diff = fork_ops[i].timestamp - fork_ops[winner_idx].timestamp; 2019 2020 if time_diff <= chrono::Duration::hours(72) { 2021 // Within recovery window - this operation wins 2022 fork_ops[winner_idx].is_winner = false; 2023 fork_ops[winner_idx].rejection_reason = Some(format!( 2024 "Invalidated by higher-priority key[{}] within recovery window", 2025 competing_idx 2026 )); 2027 2028 fork_ops[i].is_winner = true; 2029 winner_idx = i; 2030 } else { 2031 // Outside recovery window 2032 fork_ops[i].rejection_reason = Some(format!( 2033 "Higher-priority key[{}] but outside 72-hour recovery window ({:.1}h late)", 2034 competing_idx, 2035 time_diff.num_hours() as f64 2036 )); 2037 } 2038 } else { 2039 // Lower priority 2040 fork_ops[i].rejection_reason = Some(format!( 2041 "Lower-priority key[{}] (current winner has key[{}])", 2042 competing_idx, winner_idx_val 2043 )); 2044 } 2045 } 2046 _ => { 2047 fork_ops[i].rejection_reason = Some("Could not determine signing key".to_string()); 2048 } 2049 } 2050 } 2051 2052 fork_ops[winner_idx].cid.clone() 2053} 2054 2055/// Find which rotation key signed an operation 2056fn find_signing_key( 2057 operation: &Operation, 2058 rotation_keys: &[String], 2059) -> (Option<usize>, Option<String>) { 2060 for (index, key_did) in rotation_keys.iter().enumerate() { 2061 if let Ok(verifying_key) = VerifyingKey::from_did_key(key_did) 2062 && operation.verify(&[verifying_key]).is_ok() 2063 { 2064 return (Some(index), Some(key_did.clone())); 2065 } 2066 } 2067 (None, None) 2068} 2069 2070/// Get state from an operation 2071fn get_state_from_operation(operation: &Operation) -> PlcState { 2072 match operation { 2073 Operation::PlcOperation { 2074 rotation_keys, 2075 verification_methods, 2076 also_known_as, 2077 services, 2078 .. 2079 } => PlcState { 2080 rotation_keys: rotation_keys.clone(), 2081 verification_methods: verification_methods.clone(), 2082 also_known_as: also_known_as.clone(), 2083 services: services.clone(), 2084 }, 2085 _ => PlcState::new(), 2086 } 2087} 2088 2089/// Parse ISO 8601 timestamp 2090fn parse_timestamp(timestamp: &str) -> chrono::DateTime<chrono::Utc> { 2091 timestamp 2092 .parse::<chrono::DateTime<chrono::Utc>>() 2093 .unwrap_or_else(|_| chrono::Utc::now()) 2094} 2095 2096/// Visualize forks as a tree 2097fn visualize_tree(audit_log: &[AuditLogEntry], forks: &[ForkPoint], verbose: bool) { 2098 println!("📊 Fork Visualization (Tree Format)"); 2099 println!("═══════════════════════════════════════════════════════════════"); 2100 println!(); 2101 2102 // Build a map of which operations are part of forks 2103 let mut fork_map: HashMap<String, &ForkPoint> = HashMap::new(); 2104 for fork in forks { 2105 for op in &fork.operations { 2106 fork_map.insert(op.cid.clone(), fork); 2107 } 2108 } 2109 2110 // Track which prev CIDs have been processed 2111 let mut processed_forks: std::collections::HashSet<String> = std::collections::HashSet::new(); 2112 2113 for entry in audit_log.iter() { 2114 let is_genesis = entry.operation.is_genesis(); 2115 let prev = entry.operation.prev(); 2116 2117 // Check if this operation is part of a fork 2118 if let Some(_prev_cid) = prev 2119 && let Some(fork) = fork_map.get(&entry.cid) 2120 { 2121 // This is a fork point 2122 if !processed_forks.contains(&fork.prev_cid) { 2123 processed_forks.insert(fork.prev_cid.clone()); 2124 2125 println!( 2126 "Fork at operation referencing {}", 2127 truncate_cid(&fork.prev_cid) 2128 ); 2129 2130 for (j, fork_op) in fork.operations.iter().enumerate() { 2131 let symbol = if fork_op.is_winner { "" } else { "" }; 2132 let color = if fork_op.is_winner { "🟢" } else { "🔴" }; 2133 let prefix = if j == fork.operations.len() - 1 { 2134 "└─" 2135 } else { 2136 "├─" 2137 }; 2138 2139 println!( 2140 " {} {} {} CID: {}", 2141 prefix, 2142 color, 2143 symbol, 2144 truncate_cid(&fork_op.cid) 2145 ); 2146 2147 if let Some(key_idx) = fork_op.signing_key_index { 2148 println!(" │ Signed by: rotation_key[{}]", key_idx); 2149 if verbose && let Some(key) = &fork_op.signing_key { 2150 println!(" │ Key: {}", truncate_cid(key)); 2151 } 2152 } 2153 2154 println!( 2155 " │ Timestamp: {}", 2156 fork_op.timestamp.format("%Y-%m-%d %H:%M:%S UTC") 2157 ); 2158 2159 if !fork_op.is_winner { 2160 if let Some(reason) = &fork_op.rejection_reason { 2161 println!(" │ Reason: {}", reason); 2162 } 2163 } else { 2164 println!(" │ Status: CANONICAL (winner)"); 2165 } 2166 2167 if j < fork.operations.len() - 1 { 2168 println!(""); 2169 } 2170 } 2171 println!(); 2172 } 2173 continue; 2174 } 2175 2176 // Regular operation (not part of a fork) 2177 if is_genesis { 2178 println!("🌱 Genesis"); 2179 println!(" CID: {}", truncate_cid(&entry.cid)); 2180 println!(" Timestamp: {}", entry.created_at); 2181 if verbose && let Operation::PlcOperation { rotation_keys, .. } = &entry.operation { 2182 println!(" Rotation keys: {}", rotation_keys.len()); 2183 } 2184 println!(); 2185 } 2186 } 2187 2188 // Summary 2189 println!("═══════════════════════════════════════════════════════════════"); 2190 println!("📈 Summary:"); 2191 println!(" Total operations: {}", audit_log.len()); 2192 println!(" Fork points: {}", forks.len()); 2193 2194 let total_competing_ops: usize = forks.iter().map(|f| f.operations.len()).sum(); 2195 let rejected_ops = total_competing_ops - forks.len(); 2196 println!(" Rejected operations: {}", rejected_ops); 2197 2198 if !forks.is_empty() { 2199 println!("\n🔐 Fork Resolution Details:"); 2200 for (i, fork) in forks.iter().enumerate() { 2201 let winner = fork.operations.iter().find(|op| op.is_winner).unwrap(); 2202 println!( 2203 " Fork {}: Winner is {} (signed by key[{}])", 2204 i + 1, 2205 truncate_cid(&winner.cid), 2206 winner.signing_key_index.unwrap_or(999) 2207 ); 2208 } 2209 } 2210} 2211 2212/// Visualize linear chain (no forks) 2213fn visualize_linear_chain(audit_log: &[AuditLogEntry]) { 2214 for (i, entry) in audit_log.iter().enumerate() { 2215 let symbol = if i == 0 { "🌱" } else { "" }; 2216 println!("{} Operation {}: {}", symbol, i, truncate_cid(&entry.cid)); 2217 println!(" Timestamp: {}", entry.created_at); 2218 if let Some(prev) = entry.operation.prev() { 2219 println!(" Previous: {}", truncate_cid(prev)); 2220 } 2221 } 2222} 2223 2224/// Truncate a CID for display 2225fn truncate_cid(cid: &str) -> String { 2226 if cid.len() > 20 { 2227 format!("{}...{}", &cid[..8], &cid[cid.len() - 8..]) 2228 } else { 2229 cid.to_string() 2230 } 2231}