Skip to main content

ignition_core/actions/
diagnostics.rs

1//! Diagnostics-bundle actions (09-05, EXT-02; 09-07 Invalid semantics)
2//! — generate / status / wait / download over the wire landed in this
3//! phase.
4//!
5//! **The wait rides the ONE poll engine** ([`crate::poll`], the
6//! restart_and_wait shape): the probe polls
7//! `GET /data/api/v1/diagnostics/bundle/status` and reports, in THIS
8//! order —
9//!
10//! - `PollState::Pending("state … — still generating")` while
11//!   `Generating` (`is_generating`);
12//! - `Err(CoreError::BundleNotAvailable { state })` IMMEDIATELY when
13//!   `is_bundle_unavailable` (`Invalid` — the captured TERMINAL
14//!   steady state, 09-UAT.md Gap 3): zero further polls — only a
15//!   fresh generate changes this state, so waiting is structurally
16//!   futile (exit 6, `bundle_not_available`);
17//! - `PollState::Done(wire)` when the state is a captured
18//!   non-generating, non-unavailable state (`Valid` — terminal
19//!   success);
20//! - `PollState::Pending("unknown state … — still waiting")` for a
21//!   state OUTSIDE the captured vocabulary (Pitfall 2's honest-
22//!   unknowns rule — a state the captures never saw must not be
23//!   declared terminal; it keeps polling).
24//!
25//! Deadline expiry is the poll engine's `CoreError::Network {
26//! source: None }` convention (exit 4, `network_error` slug — NO new
27//! slug) naming the subject; the last observation rides the dedicated
28//! `observation` field, so a deadline the gateway ANSWERED leads with
29//! "no terminal state" and never claims unreachability (09-07).
30//!
31//! The download applies the default naming when the caller passes no
32//! `--output`: the backup/logs `.part` rename pattern — stream to
33//! `ignition-diagnostics-bundle-<unix_ts>.zip.part`, rename to the
34//! `Content-Disposition` basename when the gateway sends one
35//! (sanitized), else the fallback. Bytes are never touched by this
36//! layer (the streaming pipeline owns them).
37
38use std::path::{Path, PathBuf};
39use std::sync::Mutex;
40use std::time::{Duration, SystemTime, UNIX_EPOCH};
41
42use serde::Serialize;
43
44use crate::client::GatewayApi;
45use crate::client::diagnostics::{self};
46use crate::error::CoreError;
47use crate::poll::{self, PollConfig, PollState};
48
49// The wire re-exports for the CLI/TUI render seams (the actions
50// module is the import home for envelope payloads).
51pub use crate::client::diagnostics::{
52    BUNDLE_CAPTURED_STATES, BUNDLE_DOWNLOAD_TIMEOUT, BUNDLE_GENERATING_STATES,
53    BUNDLE_UNAVAILABLE_STATES, BundleStatusWire, is_bundle_unavailable, is_generating,
54};
55
56/// `ign diagnostics bundle generate` / `status` / `wait` — the three
57/// status-carrying verbs return the CAPTURED wire directly (the
58/// envelope's `data.state` / `data.fileSize` are the gateway's own
59/// shape; no wrapper re-keys it).
60///
61/// `ign diagnostics bundle download` output model — all keys always.
62#[derive(Debug, Serialize)]
63pub struct BundleDownloadResult {
64    /// The file written (as resolved: the `-o` override, the
65    /// gateway's `Content-Disposition` basename, or the
66    /// `ignition-diagnostics-bundle-<unix_ts>.zip` fallback).
67    pub file: String,
68    /// Bytes written (== the status `fileSize` on the captures).
69    pub bytes: u64,
70    /// Response `Content-Type` — `application/zip;charset=utf-8` on
71    /// the captures.
72    pub content_type: Option<String>,
73}
74
75/// Start bundle generation — thin wrapper; the 200 body IS the fresh
76/// status wire (live capture).
77pub async fn bundle_generate(api: &dyn GatewayApi) -> Result<BundleStatusWire, CoreError> {
78    api.bundle_generate().await
79}
80
81/// Read the bundle status — thin wrapper.
82pub async fn bundle_status(api: &dyn GatewayApi) -> Result<BundleStatusWire, CoreError> {
83    api.bundle_status().await
84}
85
86/// Poll the status until the bundle is ready. The captured
87/// TERMINAL-unavailable states (`Invalid`) refuse IMMEDIATELY (exit
88/// 6, `bundle_not_available` — no further polls, only a fresh
89/// generate changes them); `Valid` is terminal success; unknown
90/// states keep polling (honest unknowns); deadline → the poll
91/// engine's Network-class timeout (exit 4, `network_error`) with the
92/// last observation riding the dedicated field — a deadline the
93/// gateway ANSWERED never claims unreachability (09-07).
94pub async fn bundle_wait(
95    api: &dyn GatewayApi,
96    interval: Duration,
97    timeout: Duration,
98) -> Result<BundleStatusWire, CoreError> {
99    let cfg = PollConfig {
100        subject: "diagnostics bundle generation".to_string(),
101        interval,
102        deadline: timeout,
103        ..PollConfig::default()
104    };
105    // The terminal wire rides the Mutex the state borrows (poll's T
106    // is `()`; Mutex not Cell so the probe future is Send — the
107    // 06-02 TUI spawns it). The wait_module shape verbatim.
108    let mut final_wire = Mutex::new(None);
109    poll::poll(cfg, &mut final_wire, |final_wire| {
110        Box::pin(async {
111            let wire = api.bundle_status().await?;
112            let state = wire.state.as_str();
113            if diagnostics::is_generating(state) {
114                Ok(PollState::<()>::Pending(Some(format!(
115                    "state {state:?} — still generating"
116                ))))
117            } else if diagnostics::is_bundle_unavailable(state) {
118                // The unavailable arm MUST precede the captured-Done
119                // arm (Invalid is in BUNDLE_CAPTURED_STATES too):
120                // abort immediately — zero further polls, the poll
121                // engine never retries a non-transient error class.
122                Err(CoreError::BundleNotAvailable {
123                    state: state.to_string(),
124                })
125            } else if diagnostics::BUNDLE_CAPTURED_STATES.contains(&state) {
126                *final_wire.get_mut().expect("terminal wire") = Some(wire);
127                Ok(PollState::<()>::Done(()))
128            } else {
129                Ok(PollState::<()>::Pending(Some(format!(
130                    "unknown state {state:?} — still waiting"
131                ))))
132            }
133        })
134    })
135    .await?;
136    Ok(
137        std::mem::take(&mut *final_wire.lock().expect("terminal wire"))
138            .expect("poll returns only on Done — the wire has landed"),
139    )
140}
141
142/// A filesystem-safe fallback filename strip (the backup sanitizer's
143/// twin — disposition basenames are gateway-controlled, never trusted
144/// into a path with separators intact).
145fn sanitize_basename(raw: &str) -> Option<String> {
146    let trimmed = raw.trim();
147    if trimmed.is_empty() {
148        return None;
149    }
150    Some(trimmed.replace(['/', '\\'], "_"))
151}
152
153/// The default bundle filename when the gateway sends no
154/// `Content-Disposition` — the logs-download naming precedent
155/// (timestamped, deterministic).
156fn default_bundle_name(now_secs: u64) -> String {
157    format!("ignition-diagnostics-bundle-{now_secs}.zip")
158}
159
160/// `ign diagnostics bundle download` — stream the ZIP to disk. With
161/// `--output` the bytes land exactly there; the default naming rides
162/// the `.part` rename pattern (a failed download leaves no
163/// half-written impostor).
164pub async fn bundle_download(
165    api: &dyn GatewayApi,
166    out: Option<&Path>,
167) -> Result<BundleDownloadResult, CoreError> {
168    let now_secs = SystemTime::now()
169        .duration_since(UNIX_EPOCH)
170        .map(|since| since.as_secs())
171        .unwrap_or_default();
172    if let Some(out) = out {
173        let meta = api.bundle_download(out).await?;
174        return Ok(BundleDownloadResult {
175            file: out.display().to_string(),
176            bytes: meta.bytes,
177            content_type: meta.content_type,
178        });
179    }
180    let fallback = default_bundle_name(now_secs);
181    let part = PathBuf::from(format!("{fallback}.part"));
182    let meta = match api.bundle_download(&part).await {
183        Ok(meta) => meta,
184        Err(err) => {
185            let _ = std::fs::remove_file(&part); // best-effort
186            return Err(err);
187        }
188    };
189    let final_name = meta
190        .filename
191        .as_deref()
192        .and_then(sanitize_basename)
193        .unwrap_or(fallback);
194    if let Err(err) = std::fs::rename(&part, &final_name) {
195        let _ = std::fs::remove_file(&part); // best-effort
196        return Err(CoreError::Internal(format!(
197            "cannot finalize bundle {final_name}: {err}"
198        )));
199    }
200    Ok(BundleDownloadResult {
201        file: final_name,
202        bytes: meta.bytes,
203        content_type: meta.content_type,
204    })
205}
206
207#[cfg(test)]
208mod tests {
209    use std::collections::VecDeque;
210    use std::sync::Mutex;
211    use std::time::Duration;
212
213    use super::bundle_wait;
214    use crate::client::GatewayApi;
215    use crate::client::diagnostics::BundleStatusWire;
216    use crate::error::CoreError;
217
218    /// A scripted status rig: pops states in order; an exhausted
219    /// script replays the fallback forever (the deadline path needs a
220    /// never-terminal answer without an unbounded script).
221    struct WaitRig {
222        states: Mutex<VecDeque<&'static str>>,
223        fallback: &'static str,
224        calls: Mutex<usize>,
225    }
226
227    impl WaitRig {
228        fn with(states: &[&'static str], fallback: &'static str) -> Self {
229            Self {
230                states: Mutex::new(states.iter().copied().collect()),
231                fallback,
232                calls: Mutex::new(0),
233            }
234        }
235
236        fn calls(&self) -> usize {
237            *self.calls.lock().expect("calls lock")
238        }
239    }
240
241    #[async_trait::async_trait]
242    impl GatewayApi for WaitRig {
243        async fn bundle_generate(
244            &self,
245        ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
246            unreachable!("not part of this action")
247        }
248        async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError> {
249            let mut queue = self.states.lock().expect("states lock");
250            *self.calls.lock().expect("calls lock") += 1;
251            let state = queue.pop_front().unwrap_or(self.fallback);
252            Ok(BundleStatusWire {
253                state: state.to_string(),
254                file_size: (state == "Valid").then_some(61053),
255                extra: Default::default(),
256            })
257        }
258        async fn bundle_download(
259            &self,
260            _out: &std::path::Path,
261        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
262            unreachable!("not part of this action")
263        }
264        async fn tag_provider_list(
265            &self,
266            _query: &crate::client::query::ListQuery,
267        ) -> Result<
268            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
269            CoreError,
270        > {
271            unreachable!("not part of this action")
272        }
273        async fn tag_provider_find(
274            &self,
275            _name: &str,
276        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
277            unreachable!("not part of this action")
278        }
279        async fn tag_provider_create(
280            &self,
281            _body: &[crate::client::tags::TagProviderCreate],
282        ) -> Result<(), CoreError> {
283            unreachable!("not part of this action")
284        }
285        async fn tag_provider_delete(
286            &self,
287            _name: &str,
288            _signature: &str,
289        ) -> Result<(), CoreError> {
290            unreachable!("not part of this action")
291        }
292        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
293            unreachable!("not part of this action")
294        }
295        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
296            unreachable!("not part of this action")
297        }
298        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
299            unreachable!("not part of this action")
300        }
301        async fn backup_download(
302            &self,
303            _out: &std::path::Path,
304            _backup_type: crate::client::backup::BackupType,
305        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
306            unreachable!("not part of this action")
307        }
308        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
309            unreachable!("not part of this action")
310        }
311        async fn eam_task_history(
312            &self,
313            _limit: Option<u32>,
314            _search: Option<&str>,
315        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
316        {
317            unreachable!("not part of this action")
318        }
319        async fn eam_task_definitions(
320            &self,
321        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
322        {
323            unreachable!("not part of this action")
324        }
325        async fn eam_task_find(
326            &self,
327            _name: &str,
328        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
329            unreachable!("not part of this action")
330        }
331        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
332            unreachable!("not part of this action")
333        }
334        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
335            unreachable!("not part of this action")
336        }
337        async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
338            unreachable!("not part of this action")
339        }
340        async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
341            unreachable!("not part of this action")
342        }
343        async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
344            unreachable!("not part of this action")
345        }
346        async fn eam_tasks_scheduled(
347            &self,
348            _running: bool,
349        ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
350            unreachable!("not part of this action")
351        }
352        async fn eam_task_modify(
353            &self,
354            _definition: &serde_json::Value,
355        ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
356            unreachable!("not part of this action")
357        }
358        async fn eam_task_delete(
359            &self,
360            _name: &str,
361            _signature: &str,
362            _confirm: bool,
363        ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
364            unreachable!("not part of this action")
365        }
366        async fn api_call(
367            &self,
368            _call: &crate::client::apicall::ApiCallRequest,
369        ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
370            unreachable!("not part of this action")
371        }
372        async fn license_status(
373            &self,
374        ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
375            unreachable!("not part of this action")
376        }
377        async fn redundancy_status(
378            &self,
379        ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
380            unreachable!("not part of this action")
381        }
382        async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
383            unreachable!("not part of this action")
384        }
385        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
386            unreachable!("not part of this action")
387        }
388        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
389            unreachable!("not part of this action")
390        }
391        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
392            unreachable!("not part of this action")
393        }
394        async fn modules(
395            &self,
396            _quarantined: bool,
397            _query: &crate::client::query::ListQuery,
398        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
399        {
400            unreachable!("not part of this action")
401        }
402        async fn metrics_current(
403            &self,
404        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
405            unreachable!("not part of this action")
406        }
407        async fn metrics_historic(
408            &self,
409        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
410            unreachable!("not part of this action")
411        }
412        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
413            unreachable!("not part of this action")
414        }
415        async fn designers(
416            &self,
417            _query: &crate::client::query::ListQuery,
418        ) -> Result<
419            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
420            CoreError,
421        > {
422            unreachable!("not part of this action")
423        }
424        async fn perspective_sessions(
425            &self,
426            _query: &crate::client::query::ListQuery,
427        ) -> Result<
428            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
429            CoreError,
430        > {
431            unreachable!("not part of this action")
432        }
433        async fn vision_clients(
434            &self,
435            _query: &crate::client::query::ListQuery,
436        ) -> Result<
437            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
438            CoreError,
439        > {
440            unreachable!("not part of this action")
441        }
442        async fn terminate_perspective_session(
443            &self,
444            _id: &str,
445            _message: Option<&str>,
446        ) -> Result<(), CoreError> {
447            unreachable!("not part of this action")
448        }
449        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
450            unreachable!("not part of this action")
451        }
452        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
453            unreachable!("not part of this action")
454        }
455        async fn database_connections(
456            &self,
457        ) -> Result<
458            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
459            CoreError,
460        > {
461            unreachable!("not part of this action")
462        }
463        async fn opc_connections(
464            &self,
465        ) -> Result<
466            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
467            CoreError,
468        > {
469            unreachable!("not part of this action")
470        }
471
472        async fn logs(
473            &self,
474            _filter: &crate::client::logs::LogQuery,
475        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
476        {
477            unreachable!("not part of this double's actions")
478        }
479        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
480            unreachable!("not part of this double's actions")
481        }
482        async fn loggers(
483            &self,
484            _query: &crate::client::query::ListQuery,
485        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
486        {
487            unreachable!("not part of this double's actions")
488        }
489        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
490            unreachable!("not part of this double's actions")
491        }
492        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
493            unreachable!("not part of this double's actions")
494        }
495        async fn restart(&self) -> Result<(), CoreError> {
496            unreachable!("not part of this action")
497        }
498        async fn scan_projects(&self) -> Result<(), CoreError> {
499            unreachable!("not part of this action")
500        }
501        async fn security_properties(
502            &self,
503        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
504            unreachable!("not part of this action")
505        }
506        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
507            unreachable!("not part of this action")
508        }
509        async fn webdev_route_call(
510            &self,
511            _project: &str,
512            _route: &str,
513            _body: &serde_json::Value,
514            _extra_headers: &[(&str, &str)],
515        ) -> Result<serde_json::Value, CoreError> {
516            unreachable!("not part of this action")
517        }
518        async fn webdev_route_probe(
519            &self,
520            _project: &str,
521            _route: &str,
522            _extra_headers: &[(&str, &str)],
523        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
524            unreachable!("not part of this action")
525        }
526        async fn projects(
527            &self,
528            _query: &crate::client::query::ListQuery,
529        ) -> Result<
530            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
531            CoreError,
532        > {
533            unreachable!("not part of this action")
534        }
535        async fn project_find(
536            &self,
537            _name: &str,
538        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
539            unreachable!("not part of this action")
540        }
541        async fn project_create(
542            &self,
543            _body: &crate::client::projects::ProjectCreate,
544        ) -> Result<(), CoreError> {
545            unreachable!("not part of this action")
546        }
547        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
548            unreachable!("not part of this action")
549        }
550        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
551            unreachable!("not part of this action")
552        }
553        async fn project_modify(
554            &self,
555            _name: &str,
556            _body: &crate::client::projects::ProjectModify,
557        ) -> Result<(), CoreError> {
558            unreachable!("not part of this action")
559        }
560        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
561            unreachable!("not part of this action")
562        }
563        async fn project_export_to_file(
564            &self,
565            _name: &str,
566            _out: &std::path::Path,
567        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
568            unreachable!("not part of this action")
569        }
570        async fn project_import(
571            &self,
572            _name: &str,
573            _zip: Vec<u8>,
574            _overwrite: bool,
575        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
576            unreachable!("not part of this action")
577        }
578    }
579
580    /// Generating → Generating → Valid: Done on the THIRD probe (the
581    /// wait flips exactly when the captured terminal state appears).
582    #[tokio::test]
583    async fn wait_polls_until_a_captured_terminal_state() {
584        let rig = WaitRig::with(&["Generating", "Generating", "Valid"], "Generating");
585        let out = bundle_wait(&rig, Duration::from_millis(1), Duration::from_secs(10))
586            .await
587            .expect("terminal state reached");
588        assert_eq!(out.state, "Valid");
589        assert_eq!(out.file_size, Some(61_053));
590        assert_eq!(rig.calls(), 3, "exactly three probes");
591    }
592
593    /// Immediate terminal: the FIRST probe answers (poll runs the
594    /// first probe with no initial sleep).
595    #[tokio::test]
596    async fn wait_returns_on_the_first_probe_when_already_terminal() {
597        let rig = WaitRig::with(&["Valid"], "Valid");
598        let out = bundle_wait(&rig, Duration::from_millis(1), Duration::from_secs(10))
599            .await
600            .expect("immediate terminal");
601        assert_eq!(out.state, "Valid");
602        assert_eq!(rig.calls(), 1);
603    }
604
605    /// An UNKNOWN state (outside the captured vocabulary) is NOT
606    /// terminal — the wait keeps polling to the deadline, which is
607    /// the poll engine's Network{source: None} convention (exit 4,
608    /// `network_error` — no new slug) with the last observation (the
609    /// unknown state) riding the message.
610    #[tokio::test]
611    async fn unknown_states_keep_polling_until_the_deadline() {
612        let rig = WaitRig::with(&["Generating", "Mystery"], "Mystery");
613        let err = bundle_wait(&rig, Duration::from_millis(1), Duration::from_millis(40))
614            .await
615            .expect_err("deadline must expire");
616        assert!(
617            matches!(&err, CoreError::Network { source: None, .. }),
618            "deadline = Network with no transport source: {err}"
619        );
620        assert_eq!(err.exit_code(), 4);
621        assert_eq!(err.code(), "network_error");
622        let message = err.to_string();
623        assert!(
624            message.contains("diagnostics bundle generation"),
625            "subject named: {message}"
626        );
627        assert!(
628            message.contains("unknown state") && message.contains("Mystery"),
629            "the final status rides the deadline observation: {message}"
630        );
631        assert!(
632            !message.contains("unreachable"),
633            "the gateway ANSWERED — the deadline message never claims unreachability (09-07): {message}"
634        );
635        assert!(
636            rig.calls() > 2,
637            "unknown states keep polling: {}",
638            rig.calls()
639        );
640    }
641
642    /// THE 09-07 Gap-3 semantics: `Invalid` is the captured TERMINAL
643    /// steady state ("no current bundle") — the wait refuses
644    /// IMMEDIATELY (exit 6, `bundle_not_available`) after EXACTLY the
645    /// two probes, with NO deadline wait: polling cannot change this
646    /// state, only a fresh generate can. The message names the
647    /// observed state and the generate command.
648    #[tokio::test]
649    async fn invalid_state_exits_immediately_bundle_not_available() {
650        let rig = WaitRig::with(&["Generating", "Invalid"], "Invalid");
651        let err = bundle_wait(&rig, Duration::from_millis(1), Duration::from_secs(10))
652            .await
653            .expect_err("Invalid is terminal-unavailable");
654        assert_eq!(err.exit_code(), 6, "target state");
655        assert_eq!(err.code(), "bundle_not_available");
656        assert_eq!(
657            rig.calls(),
658            2,
659            "EXACTLY two probes — immediate exit, no further polls, no deadline wait"
660        );
661        let message = err.to_string();
662        assert!(
663            message.contains("Invalid"),
664            "the observed state is named: {message}"
665        );
666        assert!(
667            message.contains("generate"),
668            "the fresh-generate fix is named: {message}"
669        );
670        let hint = err.hint().expect("hint required");
671        assert!(
672            hint.contains("ign diagnostics bundle generate"),
673            "the hint names the generate command verbatim: {hint}"
674        );
675    }
676
677    /// The download fallback name is deterministic and timestamped
678    /// (the logs-download naming precedent).
679    #[test]
680    fn default_bundle_name_shape() {
681        assert_eq!(
682            super::default_bundle_name(1_788_748_551),
683            "ignition-diagnostics-bundle-1788748551.zip"
684        );
685    }
686
687    /// The sanitizer strips separators from gateway-controlled
688    /// disposition names (the backup twin).
689    #[test]
690    fn basename_sanitizer_strips_separators() {
691        assert_eq!(
692            super::sanitize_basename("diag.zip").as_deref(),
693            Some("diag.zip")
694        );
695        assert_eq!(
696            super::sanitize_basename("../../etc/passwd").as_deref(),
697            Some(".._.._etc_passwd")
698        );
699        assert_eq!(super::sanitize_basename("   "), None, "blank names nothing");
700    }
701}