Skip to main content

auths_cli/commands/
emergency.rs

1//! Emergency response commands for incident handling.
2//!
3//! Commands:
4//! - `auths emergency` - Interactive emergency response flow
5//! - `auths emergency revoke-device` - Revoke a compromised device
6//! - `auths emergency rotate-now` - Force key rotation
7//! - `auths emergency freeze` - Freeze all operations
8//! - `auths emergency report` - Generate incident report
9
10use crate::ux::format::{Output, is_json_mode};
11use anyhow::{Context, Result, anyhow};
12use clap::{Parser, Subcommand};
13use dialoguer::{Confirm, Input, Select};
14use serde::{Deserialize, Serialize};
15use std::io::IsTerminal;
16use std::path::PathBuf;
17
18/// Emergency incident response commands.
19#[derive(Parser, Debug, Clone)]
20#[command(name = "emergency", about = "Emergency incident response commands")]
21pub struct EmergencyCommand {
22    #[command(subcommand)]
23    pub command: Option<EmergencySubcommand>,
24}
25
26#[derive(Subcommand, Debug, Clone)]
27pub enum EmergencySubcommand {
28    /// Revoke a compromised device immediately.
29    #[command(name = "revoke-device")]
30    RevokeDevice(RevokeDeviceCommand),
31
32    /// Force immediate key rotation.
33    #[command(name = "rotate-now")]
34    RotateNow(RotateNowCommand),
35
36    /// Freeze all signing operations.
37    Freeze(FreezeCommand),
38
39    /// Unfreeze (cancel an active freeze early).
40    Unfreeze(UnfreezeCommand),
41
42    /// Generate an incident report.
43    Report(ReportCommand),
44}
45
46/// Revoke a compromised device.
47#[derive(Parser, Debug, Clone)]
48pub struct RevokeDeviceCommand {
49    /// Device DID to revoke.
50    #[arg(long)]
51    pub device: Option<String>,
52
53    /// Local alias of the identity's key (used for signing the revocation).
54    #[arg(long)]
55    pub identity_key_alias: Option<String>,
56
57    /// Optional note explaining the revocation.
58    #[arg(long)]
59    pub note: Option<String>,
60
61    /// Skip confirmation prompt.
62    #[arg(long, short = 'y')]
63    pub yes: bool,
64
65    /// Preview actions without making changes.
66    #[arg(long)]
67    pub dry_run: bool,
68
69    /// Path to the Auths repository.
70    #[arg(long)]
71    pub repo: Option<PathBuf>,
72}
73
74/// Force immediate key rotation.
75#[derive(Parser, Debug, Clone)]
76pub struct RotateNowCommand {
77    /// Local alias of the current signing key.
78    #[arg(long)]
79    pub current_alias: Option<String>,
80
81    /// Local alias for the new signing key after rotation.
82    #[arg(long)]
83    pub next_alias: Option<String>,
84
85    /// Skip confirmation prompt (requires typing ROTATE).
86    #[arg(long, short = 'y')]
87    pub yes: bool,
88
89    /// Preview actions without making changes.
90    #[arg(long)]
91    pub dry_run: bool,
92
93    /// Reason for rotation.
94    #[arg(long)]
95    pub reason: Option<String>,
96
97    /// Path to the Auths repository.
98    #[arg(long)]
99    pub repo: Option<PathBuf>,
100}
101
102/// Freeze all signing operations.
103#[derive(Parser, Debug, Clone)]
104pub struct FreezeCommand {
105    /// Duration to freeze (e.g., "24h", "7d").
106    #[arg(long, default_value = "24h")]
107    pub duration: String,
108
109    /// Skip confirmation prompt (requires typing identity name).
110    #[arg(long, short = 'y')]
111    pub yes: bool,
112
113    /// Preview actions without making changes.
114    #[arg(long)]
115    pub dry_run: bool,
116
117    /// Path to the Auths repository.
118    #[arg(long)]
119    pub repo: Option<PathBuf>,
120}
121
122/// Cancel an active freeze early.
123#[derive(Parser, Debug, Clone)]
124pub struct UnfreezeCommand {
125    /// Skip confirmation prompt.
126    #[arg(long, short = 'y')]
127    pub yes: bool,
128
129    /// Path to the Auths repository.
130    #[arg(long)]
131    pub repo: Option<PathBuf>,
132}
133
134/// Generate an incident report.
135#[derive(Parser, Debug, Clone)]
136pub struct ReportCommand {
137    /// Include last N events in report.
138    #[arg(long, default_value = "100")]
139    pub events: usize,
140
141    /// Output file path (defaults to stdout).
142    #[arg(long = "file", short = 'o')]
143    pub output_file: Option<PathBuf>,
144
145    /// Path to the Auths repository.
146    #[arg(long)]
147    pub repo: Option<PathBuf>,
148}
149
150/// Incident report output.
151#[derive(Debug, Serialize, Deserialize)]
152pub struct IncidentReport {
153    pub generated_at: String,
154    pub identity_did: Option<String>,
155    pub devices: Vec<DeviceInfo>,
156    pub recent_events: Vec<EventInfo>,
157    pub recommendations: Vec<String>,
158}
159
160#[derive(Debug, Serialize, Deserialize)]
161pub struct DeviceInfo {
162    pub did: String,
163    pub name: Option<String>,
164    pub status: String,
165    pub last_active: Option<String>,
166}
167
168#[derive(Debug, Serialize, Deserialize)]
169pub struct EventInfo {
170    pub timestamp: String,
171    pub event_type: String,
172    pub details: String,
173}
174
175/// Handle the emergency command.
176pub fn handle_emergency(cmd: EmergencyCommand) -> Result<()> {
177    match cmd.command {
178        Some(EmergencySubcommand::RevokeDevice(c)) => handle_revoke_device(c),
179        Some(EmergencySubcommand::RotateNow(c)) => handle_rotate_now(c),
180        Some(EmergencySubcommand::Freeze(c)) => handle_freeze(c),
181        Some(EmergencySubcommand::Unfreeze(c)) => handle_unfreeze(c),
182        Some(EmergencySubcommand::Report(c)) => handle_report(c),
183        None => handle_interactive_flow(),
184    }
185}
186
187/// Handle interactive emergency flow.
188fn handle_interactive_flow() -> Result<()> {
189    let out = Output::new();
190
191    if !std::io::stdin().is_terminal() {
192        return Err(anyhow!(
193            "Interactive mode requires a terminal. Use subcommands for non-interactive use."
194        ));
195    }
196
197    out.newline();
198    out.println(&format!(
199        "  {} {}",
200        out.error("🚨"),
201        out.bold("Emergency Response")
202    ));
203    out.newline();
204
205    let options = [
206        "Device lost or stolen",
207        "Key may have been exposed",
208        "Freeze everything immediately",
209        "Generate incident report",
210        "Cancel",
211    ];
212
213    let selection = Select::new()
214        .with_prompt("What happened?")
215        .items(options)
216        .default(0)
217        .interact()?;
218
219    match selection {
220        0 => {
221            // Device lost/stolen
222            out.print_info("Starting device revocation flow...");
223            handle_revoke_device(RevokeDeviceCommand {
224                device: None,
225                identity_key_alias: None,
226                note: None,
227                yes: false,
228                dry_run: false,
229                repo: None,
230            })
231        }
232        1 => {
233            // Key exposed
234            out.print_info("Starting key rotation flow...");
235            handle_rotate_now(RotateNowCommand {
236                current_alias: None,
237                next_alias: None,
238                yes: false,
239                dry_run: false,
240                reason: Some("Potential key exposure".to_string()),
241                repo: None,
242            })
243        }
244        2 => {
245            // Freeze everything
246            out.print_warn("Starting freeze flow...");
247            handle_freeze(FreezeCommand {
248                duration: "24h".to_string(),
249                yes: false,
250                dry_run: false,
251                repo: None,
252            })
253        }
254        3 => {
255            // Generate report
256            handle_report(ReportCommand {
257                events: 100,
258                output_file: None,
259                repo: None,
260            })
261        }
262        _ => {
263            out.println("Cancelled.");
264            Ok(())
265        }
266    }
267}
268
269/// Handle device revocation using the real revocation code path.
270fn handle_revoke_device(cmd: RevokeDeviceCommand) -> Result<()> {
271    use auths_core::signing::{PassphraseProvider, StorageSigner};
272    use auths_core::storage::keychain::{KeyAlias, get_platform_keychain};
273    use auths_id::attestation::export::AttestationSink;
274    use auths_id::attestation::revoke::create_signed_revocation;
275    use auths_id::identity::helpers::ManagedIdentity;
276    use auths_id::storage::attestation::AttestationSource;
277    use auths_id::storage::identity::IdentityStorage;
278    use auths_id::storage::layout;
279    use auths_storage::git::{RegistryAttestationStorage, RegistryIdentityStorage};
280    use auths_verifier::Ed25519PublicKey;
281    use auths_verifier::types::DeviceDID;
282
283    let out = Output::new();
284
285    out.print_heading("Device Revocation");
286    out.newline();
287
288    // Get device to revoke
289    let device_did = if let Some(did) = cmd.device {
290        did
291    } else if std::io::stdin().is_terminal() {
292        Input::new()
293            .with_prompt("Enter device DID to revoke")
294            .interact_text()?
295    } else {
296        return Err(anyhow!("--device is required in non-interactive mode"));
297    };
298
299    // Get identity key alias
300    let identity_key_alias = if let Some(alias) = cmd.identity_key_alias {
301        alias
302    } else if std::io::stdin().is_terminal() {
303        Input::new()
304            .with_prompt("Enter identity key alias")
305            .interact_text()?
306    } else {
307        return Err(anyhow!(
308            "--identity-key-alias is required in non-interactive mode"
309        ));
310    };
311
312    out.println(&format!("Device to revoke: {}", out.info(&device_did)));
313    out.newline();
314
315    if cmd.dry_run {
316        out.print_info("Dry run mode - no changes will be made");
317        out.newline();
318        out.println("Would perform the following actions:");
319        out.println(&format!(
320            "  1. Revoke device authorization for {}",
321            device_did
322        ));
323        out.println("  2. Create signed revocation attestation");
324        out.println("  3. Store revocation in Git repository");
325        return Ok(());
326    }
327
328    // Confirmation
329    if !cmd.yes {
330        let confirm = Confirm::new()
331            .with_prompt(format!("Revoke device {}?", device_did))
332            .default(false)
333            .interact()?;
334
335        if !confirm {
336            out.println("Cancelled.");
337            return Ok(());
338        }
339    }
340
341    // Resolve repository and load identity
342    let repo_path = layout::resolve_repo_path(cmd.repo)?;
343
344    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
345    let managed_identity: ManagedIdentity = identity_storage
346        .load_identity()
347        .with_context(|| format!("Failed to load identity from repo {:?}", repo_path))?;
348
349    let controller_did = managed_identity.controller_did;
350    let rid = managed_identity.storage_id;
351
352    let device_did_obj = DeviceDID::new(device_did.clone());
353
354    // Look up the device's public key from existing attestations
355    let attestation_storage = RegistryAttestationStorage::new(repo_path.clone());
356    let existing_attestations = attestation_storage
357        .load_attestations_for_device(&device_did_obj)
358        .with_context(|| format!("Failed to load attestations for device {}", device_did_obj))?;
359    let device_public_key = existing_attestations
360        .iter()
361        .find(|a| !a.device_public_key.is_zero())
362        .map(|a| a.device_public_key)
363        .unwrap_or_else(|| Ed25519PublicKey::from_bytes([0u8; 32]));
364
365    // Create passphrase provider from terminal
366    let passphrase_provider = crate::core::provider::CliPassphraseProvider::new();
367
368    let secure_signer = StorageSigner::new(get_platform_keychain()?);
369
370    let revocation_timestamp = chrono::Utc::now();
371
372    out.print_info("Creating signed revocation attestation...");
373    let identity_key_alias = KeyAlias::new_unchecked(identity_key_alias);
374    let revocation_attestation = create_signed_revocation(
375        &rid,
376        &controller_did,
377        &device_did_obj,
378        device_public_key.as_bytes(),
379        cmd.note,
380        None,
381        revocation_timestamp,
382        &secure_signer,
383        &passphrase_provider as &dyn PassphraseProvider,
384        &identity_key_alias,
385    )
386    .map_err(anyhow::Error::from)
387    .context("Failed to create revocation attestation")?;
388
389    out.print_info("Saving revocation to Git repository...");
390    let attestation_storage = RegistryAttestationStorage::new(repo_path);
391    attestation_storage
392        .export(
393            &auths_verifier::VerifiedAttestation::dangerous_from_unchecked(revocation_attestation),
394        )
395        .context("Failed to save revocation attestation to Git repository")?;
396
397    out.print_success(&format!("Device {} has been revoked", device_did));
398    out.newline();
399    out.println("The device can no longer sign on behalf of your identity.");
400
401    Ok(())
402}
403
404/// Handle emergency key rotation using the real rotation code path.
405fn handle_rotate_now(cmd: RotateNowCommand) -> Result<()> {
406    use auths_core::storage::keychain::{KeyAlias, get_platform_keychain};
407    use auths_id::identity::rotate::rotate_keri_identity;
408    use auths_id::storage::layout::{self, StorageLayoutConfig};
409
410    let out = Output::new();
411
412    out.print_heading("Emergency Key Rotation");
413    out.newline();
414
415    let reason = cmd
416        .reason
417        .unwrap_or_else(|| "Manual emergency rotation".to_string());
418    out.println(&format!("Reason: {}", out.info(&reason)));
419    out.newline();
420
421    // Get key aliases
422    let current_alias = if let Some(alias) = cmd.current_alias {
423        alias
424    } else if std::io::stdin().is_terminal() {
425        Input::new()
426            .with_prompt("Enter current signing key alias")
427            .interact_text()?
428    } else {
429        return Err(anyhow!(
430            "--current-alias is required in non-interactive mode"
431        ));
432    };
433
434    let next_alias = if let Some(alias) = cmd.next_alias {
435        alias
436    } else if std::io::stdin().is_terminal() {
437        Input::new()
438            .with_prompt("Enter alias for the new signing key")
439            .interact_text()?
440    } else {
441        return Err(anyhow!("--next-alias is required in non-interactive mode"));
442    };
443
444    if cmd.dry_run {
445        out.print_info("Dry run mode - no changes will be made");
446        out.newline();
447        out.println("Would perform the following actions:");
448        out.println("  1. Generate new Ed25519 keypair");
449        out.println("  2. Create rotation event in identity log");
450        out.println("  3. Update key alias mappings");
451        return Ok(());
452    }
453
454    // Extra confirmation for rotation
455    if !cmd.yes {
456        out.print_warn("Key rotation is a significant operation.");
457        out.println("All devices will need to re-authorize.");
458        out.newline();
459
460        let confirmation: String = Input::new()
461            .with_prompt("Type ROTATE to confirm")
462            .interact_text()?;
463
464        if confirmation != "ROTATE" {
465            out.println("Cancelled - confirmation not matched.");
466            return Ok(());
467        }
468    }
469
470    // Resolve repository
471    let repo_path = layout::resolve_repo_path(cmd.repo)?;
472    let config = StorageLayoutConfig::default();
473
474    // Create passphrase provider from terminal
475    let passphrase_provider = crate::core::provider::CliPassphraseProvider::new();
476    let keychain = get_platform_keychain()?;
477
478    out.print_info("Rotating key...");
479    let current_alias = KeyAlias::new_unchecked(current_alias);
480    let next_alias = KeyAlias::new_unchecked(next_alias);
481    let rotation_info = rotate_keri_identity(
482        &repo_path,
483        &current_alias,
484        &next_alias,
485        &passphrase_provider,
486        &config,
487        keychain.as_ref(),
488        None,
489    )
490    .context("Key rotation failed")?;
491
492    out.print_success(&format!(
493        "Key rotation complete (new sequence: {})",
494        rotation_info.sequence
495    ));
496    out.newline();
497    out.println("Next steps:");
498    out.println("  1. Re-authorize your devices: auths device link");
499    out.println("  2. Update any CI/CD secrets");
500    out.println("  3. Run `auths doctor` to verify setup");
501
502    Ok(())
503}
504
505/// Handle freeze operation — temporarily disables all signing.
506fn handle_freeze(cmd: FreezeCommand) -> Result<()> {
507    use auths_id::freeze::{FreezeState, load_active_freeze, parse_duration, store_freeze};
508    use auths_id::storage::layout;
509
510    let out = Output::new();
511
512    out.print_heading("Identity Freeze");
513    out.newline();
514
515    // Parse duration
516    let duration = parse_duration(&cmd.duration)?;
517    let frozen_at = chrono::Utc::now();
518    let frozen_until = frozen_at + duration;
519
520    out.println(&format!(
521        "Duration: {} (until {})",
522        out.info(&cmd.duration),
523        out.info(&frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
524    ));
525    out.newline();
526
527    // Resolve repository
528    let repo_path = layout::resolve_repo_path(cmd.repo)?;
529
530    // Check for existing freeze
531    if let Some(existing) = load_active_freeze(&repo_path, chrono::Utc::now())? {
532        let existing_until = existing.frozen_until;
533        if frozen_until > existing_until {
534            out.print_warn(&format!(
535                "Existing freeze active until {}. Will extend to {}.",
536                existing_until.format("%Y-%m-%d %H:%M UTC"),
537                frozen_until.format("%Y-%m-%d %H:%M UTC"),
538            ));
539        } else {
540            out.print_warn(&format!(
541                "Existing freeze already active until {} (longer than requested).",
542                existing_until.format("%Y-%m-%d %H:%M UTC"),
543            ));
544            out.println("Use a longer duration to extend, or unfreeze first.");
545            return Ok(());
546        }
547        out.newline();
548    }
549
550    if cmd.dry_run {
551        out.print_info("Dry run mode - no changes will be made");
552        out.newline();
553        out.println("Would perform the following actions:");
554        out.println(&format!(
555            "  1. Freeze all signing operations for {}",
556            cmd.duration
557        ));
558        out.println(&format!(
559            "  2. Write freeze state to {}",
560            repo_path.join("freeze.json").display()
561        ));
562        out.println("  3. auths-sign will refuse to sign until freeze expires");
563        return Ok(());
564    }
565
566    // Confirmation
567    if !cmd.yes {
568        let confirmation: String = dialoguer::Input::new()
569            .with_prompt("Type FREEZE to confirm")
570            .interact_text()?;
571
572        if confirmation != "FREEZE" {
573            out.println("Cancelled - confirmation not matched.");
574            return Ok(());
575        }
576    }
577
578    let state = FreezeState {
579        frozen_at,
580        frozen_until,
581        reason: Some(format!("Emergency freeze for {}", cmd.duration)),
582    };
583
584    store_freeze(&repo_path, &state)?;
585
586    out.print_success(&format!(
587        "Identity frozen until {}",
588        frozen_until.format("%Y-%m-%d %H:%M UTC")
589    ));
590    out.newline();
591    out.println("All signing operations are disabled.");
592    out.println(&format!(
593        "Freeze expires in: {}",
594        out.info(&state.expires_description(chrono::Utc::now()))
595    ));
596    out.newline();
597    out.println("To unfreeze early:");
598    out.println(&format!("  {}", out.dim("auths emergency unfreeze")));
599
600    Ok(())
601}
602
603/// Handle unfreeze — cancel an active freeze early.
604fn handle_unfreeze(cmd: UnfreezeCommand) -> Result<()> {
605    use auths_id::freeze::{load_active_freeze, remove_freeze};
606    use auths_id::storage::layout;
607
608    let out = Output::new();
609
610    let repo_path = layout::resolve_repo_path(cmd.repo)?;
611
612    match load_active_freeze(&repo_path, chrono::Utc::now())? {
613        Some(state) => {
614            out.println(&format!(
615                "Active freeze until {}",
616                out.info(&state.frozen_until.format("%Y-%m-%d %H:%M UTC").to_string())
617            ));
618            out.newline();
619
620            if !cmd.yes {
621                let confirm = Confirm::new()
622                    .with_prompt("Remove freeze and restore signing?")
623                    .default(false)
624                    .interact()?;
625
626                if !confirm {
627                    out.println("Cancelled.");
628                    return Ok(());
629                }
630            }
631
632            remove_freeze(&repo_path)?;
633            out.print_success("Freeze removed. Signing operations are restored.");
634        }
635        None => {
636            out.print_info("No active freeze found.");
637        }
638    }
639
640    Ok(())
641}
642
643/// Handle incident report generation.
644fn handle_report(cmd: ReportCommand) -> Result<()> {
645    use auths_id::identity::helpers::ManagedIdentity;
646    use auths_id::storage::attestation::AttestationSource;
647    use auths_id::storage::identity::IdentityStorage;
648    use auths_id::storage::layout;
649    use auths_storage::git::{RegistryAttestationStorage, RegistryIdentityStorage};
650
651    let out = Output::new();
652
653    let repo_path = layout::resolve_repo_path(cmd.repo.clone())?;
654
655    // Load real identity
656    let identity_storage = RegistryIdentityStorage::new(repo_path.clone());
657    let identity_did = match identity_storage.load_identity() {
658        Ok(ManagedIdentity { controller_did, .. }) => Some(controller_did),
659        Err(_) => None,
660    };
661
662    // Load real device attestations
663    let attestation_storage = RegistryAttestationStorage::new(repo_path);
664    let all_attestations = attestation_storage
665        .load_all_attestations()
666        .unwrap_or_default();
667
668    // Build device list from attestations (deduplicate by subject DID)
669    let mut seen_devices = std::collections::HashSet::new();
670    let mut devices = Vec::new();
671    for att in &all_attestations {
672        let did_str = att.subject.to_string();
673        if seen_devices.insert(did_str.clone()) {
674            let status = if att.is_revoked() {
675                "revoked"
676            } else if att.expires_at.is_some_and(|exp| exp <= chrono::Utc::now()) {
677                "expired"
678            } else {
679                "active"
680            };
681            devices.push(DeviceInfo {
682                did: did_str,
683                name: att.note.clone(),
684                status: status.to_string(),
685                last_active: att.timestamp.map(|t| t.to_rfc3339()),
686            });
687        }
688    }
689
690    // Build recent events from attestation history (most recent first, capped)
691    let mut events: Vec<&auths_verifier::core::Attestation> = all_attestations.iter().collect();
692    events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
693    let recent_events: Vec<EventInfo> = events
694        .iter()
695        .take(cmd.events)
696        .map(|att| {
697            let event_type = if att.is_revoked() {
698                "device_revocation"
699            } else {
700                "device_authorization"
701            };
702            EventInfo {
703                timestamp: att.timestamp.map(|t| t.to_rfc3339()).unwrap_or_default(),
704                event_type: event_type.to_string(),
705                details: format!("{} for {}", event_type, att.subject),
706            }
707        })
708        .collect();
709
710    // Generate recommendations based on actual state
711    let mut recommendations = Vec::new();
712    let active_count = devices.iter().filter(|d| d.status == "active").count();
713    let revoked_count = devices.iter().filter(|d| d.status == "revoked").count();
714    let expired_count = devices.iter().filter(|d| d.status == "expired").count();
715
716    if active_count > 0 {
717        recommendations.push(format!(
718            "Review all {} active device authorizations",
719            active_count
720        ));
721    }
722    if expired_count > 0 {
723        recommendations.push(format!(
724            "Clean up {} expired device authorizations",
725            expired_count
726        ));
727    }
728    if revoked_count > 0 {
729        recommendations.push(format!(
730            "{} device(s) already revoked — verify these were intentional",
731            revoked_count
732        ));
733    }
734    recommendations.push("Check for any unexpected signing activity".to_string());
735
736    let report = IncidentReport {
737        generated_at: chrono::Utc::now().to_rfc3339(),
738        identity_did: identity_did.map(|d| d.to_string()),
739        devices,
740        recent_events,
741        recommendations,
742    };
743
744    if is_json_mode() {
745        let json = serde_json::to_string_pretty(&report)?;
746        if let Some(output_path) = &cmd.output_file {
747            std::fs::write(output_path, &json)
748                .with_context(|| format!("Failed to write report to {:?}", output_path))?;
749            out.print_success(&format!("Report saved to {}", output_path.display()));
750        } else {
751            println!("{}", json);
752        }
753        return Ok(());
754    }
755
756    // Text output
757    out.print_heading("Incident Report");
758    out.newline();
759
760    out.println(&format!("Generated: {}", out.info(&report.generated_at)));
761    if let Some(did) = &report.identity_did {
762        out.println(&format!("Identity: {}", out.info(did)));
763    }
764    out.newline();
765
766    out.print_heading("  Devices");
767    for device in &report.devices {
768        let status_icon = if device.status == "active" {
769            out.success("●")
770        } else {
771            out.error("○")
772        };
773        out.println(&format!(
774            "    {} {} ({}) - {}",
775            status_icon,
776            device.did,
777            device.name.as_deref().unwrap_or("unnamed"),
778            device.status
779        ));
780    }
781    out.newline();
782
783    out.print_heading("  Recent Events");
784    for event in &report.recent_events {
785        out.println(&format!(
786            "    {} [{}] {}",
787            out.dim(&event.timestamp[..19]),
788            event.event_type,
789            event.details
790        ));
791    }
792    out.newline();
793
794    out.print_heading("  Recommendations");
795    for (i, rec) in report.recommendations.iter().enumerate() {
796        out.println(&format!("    {}. {}", i + 1, rec));
797    }
798
799    if let Some(output_path) = &cmd.output_file {
800        let json = serde_json::to_string_pretty(&report)?;
801        std::fs::write(output_path, json)
802            .with_context(|| format!("Failed to write report to {:?}", output_path))?;
803        out.newline();
804        out.print_success(&format!("Report also saved to {}", output_path.display()));
805    }
806
807    Ok(())
808}
809
810use crate::commands::executable::ExecutableCommand;
811use crate::config::CliConfig;
812
813impl ExecutableCommand for EmergencyCommand {
814    fn execute(&self, _ctx: &CliConfig) -> Result<()> {
815        handle_emergency(self.clone())
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    fn test_incident_report_serialization() {
825        let report = IncidentReport {
826            generated_at: "2024-01-15T10:30:00Z".to_string(),
827            identity_did: Some("did:keri:ETest".to_string()),
828            devices: vec![],
829            recent_events: vec![],
830            recommendations: vec!["Test recommendation".to_string()],
831        };
832
833        let json = serde_json::to_string(&report).unwrap();
834        assert!(json.contains("did:keri:ETest"));
835        assert!(json.contains("Test recommendation"));
836    }
837
838    #[test]
839    fn test_device_info_serialization() {
840        let device = DeviceInfo {
841            did: "did:key:z6MkTest".to_string(),
842            name: Some("Test Device".to_string()),
843            status: "active".to_string(),
844            last_active: None,
845        };
846
847        let json = serde_json::to_string(&device).unwrap();
848        assert!(json.contains("did:key:z6MkTest"));
849        assert!(json.contains("Test Device"));
850    }
851
852    #[test]
853    fn test_freeze_dry_run() {
854        let dir = tempfile::TempDir::new().unwrap();
855        let result = handle_freeze(FreezeCommand {
856            duration: "24h".to_string(),
857            yes: true,
858            dry_run: true,
859            repo: Some(dir.path().to_path_buf()),
860        });
861
862        assert!(result.is_ok());
863        // Dry run should NOT create the freeze file
864        assert!(!dir.path().join("freeze.json").exists());
865    }
866
867    #[test]
868    fn test_freeze_creates_freeze_file() {
869        let dir = tempfile::TempDir::new().unwrap();
870        let result = handle_freeze(FreezeCommand {
871            duration: "1h".to_string(),
872            yes: true,
873            dry_run: false,
874            repo: Some(dir.path().to_path_buf()),
875        });
876
877        assert!(result.is_ok());
878        assert!(dir.path().join("freeze.json").exists());
879
880        // Verify the freeze is active
881        let state = auths_id::freeze::load_active_freeze(dir.path(), chrono::Utc::now()).unwrap();
882        assert!(state.is_some());
883    }
884
885    #[test]
886    fn test_freeze_invalid_duration() {
887        let dir = tempfile::TempDir::new().unwrap();
888        let result = handle_freeze(FreezeCommand {
889            duration: "invalid".to_string(),
890            yes: true,
891            dry_run: false,
892            repo: Some(dir.path().to_path_buf()),
893        });
894
895        assert!(result.is_err());
896        let err_msg = result.unwrap_err().to_string();
897        assert!(
898            err_msg.contains("Invalid") || err_msg.contains("duration"),
899            "Expected duration parse error, got: {}",
900            err_msg
901        );
902    }
903
904    #[test]
905    fn test_unfreeze_removes_freeze() {
906        let dir = tempfile::TempDir::new().unwrap();
907
908        // Create a freeze
909        handle_freeze(FreezeCommand {
910            duration: "24h".to_string(),
911            yes: true,
912            dry_run: false,
913            repo: Some(dir.path().to_path_buf()),
914        })
915        .unwrap();
916        assert!(dir.path().join("freeze.json").exists());
917
918        // Unfreeze
919        handle_unfreeze(UnfreezeCommand {
920            yes: true,
921            repo: Some(dir.path().to_path_buf()),
922        })
923        .unwrap();
924        assert!(!dir.path().join("freeze.json").exists());
925    }
926}