ignition_core/actions/backup.rs
1//! Standalone gwbk backup actions (07-02, BKUP-01) — the Phase 4
2//! client methods surfaced on ANY profiled gateway (not just rigs).
3//!
4//! Pure orchestration: the wire shipped in 04-04 (the streamed
5//! download through `download_to_file`, the raw octet-stream restore
6//! POST with four explicit-false scope params) — this layer owns only
7//! the default output naming (the project-export `.part` rename
8//! pattern: stream to a fallback name, rename to the
9//! Content-Disposition basename when the gateway sends one) and the
10//! usage-class file pre-checks the rig restore established (a
11//! nonexistent/empty/directory `--file` refuses exit 2 BEFORE any
12//! network work).
13//!
14//! The 8th `--yes`-guarded destructive verb lives at the CLI seam
15//! (`backup restore`, main.rs — guard BEFORE resolution, the
16//! sessions-terminate shape); the actions here stay unguarded
17//! (caller-owns-guard). The post-restore restart-block window is a
18//! README truth, not output data (Pitfall 6).
19
20use std::path::{Path, PathBuf};
21
22use serde::Serialize;
23
24use crate::client::GatewayApi;
25use crate::client::backup::BackupType;
26use crate::error::CoreError;
27
28/// `ign backup download` output model — all keys always.
29#[derive(Debug, Serialize)]
30pub struct BackupDownloadResult {
31 /// The file written (as resolved: the `-o` override, the
32 /// gateway's Content-Disposition basename, or the
33 /// `<stem>-backup.gwbk` fallback).
34 pub file: String,
35 /// The backup type requested (`roaming` | `all` — the wire
36 /// value, agent-stable).
37 pub r#type: String,
38}
39
40/// `ign backup restore` output model — the flat success shape.
41#[derive(Debug, Serialize)]
42pub struct BackupRestoreResult {
43 /// Always `true` on this shape — the POST's 2xx IS the restore
44 /// acceptance (the post-restore restart window is README
45 /// honesty, not output).
46 pub restored: bool,
47}
48
49/// A filesystem-safe fallback stem (the project-export defense —
50/// profile names are config-controlled, still never trusted into a
51/// path with separators intact).
52fn safe_stem(stem: &str) -> String {
53 stem.replace(['/', '\\'], "_")
54}
55
56/// Strip any path components from a Content-Disposition filename —
57/// the gateway names a basename, defense-in-depth makes it true (the
58/// project-export sanitizer's twin).
59fn sanitize_basename(raw: &str) -> Option<String> {
60 let trimmed = raw.trim();
61 if trimmed.is_empty() {
62 return None;
63 }
64 Some(trimmed.replace(['/', '\\'], "_"))
65}
66
67/// `ign backup download` — stream the gwbk to disk. Default naming
68/// rides the project-export `.part` pattern: stream to
69/// `<stem>-backup.gwbk.part`, rename to the disposition basename (or
70/// the fallback) once the metadata arrives; a failed download leaves
71/// no half-written impostor.
72pub async fn backup_download(
73 api: &dyn GatewayApi,
74 out: Option<&Path>,
75 host_stem: &str,
76 backup_type: BackupType,
77) -> Result<BackupDownloadResult, CoreError> {
78 let wire_type = backup_type.wire().to_string();
79 if let Some(out) = out {
80 api.backup_download(out, backup_type).await?;
81 return Ok(BackupDownloadResult {
82 file: out.display().to_string(),
83 r#type: wire_type,
84 });
85 }
86
87 // Default naming: the export convention (disposition basename
88 // wins; the sanitized fallback names the gateway).
89 let fallback = format!("{}-backup.gwbk", safe_stem(host_stem));
90 let part = PathBuf::from(format!("{fallback}.part"));
91 let meta = match api.backup_download(&part, backup_type).await {
92 Ok(meta) => meta,
93 Err(err) => {
94 let _ = std::fs::remove_file(&part); // best-effort
95 return Err(err);
96 }
97 };
98 let final_name = meta
99 .filename
100 .as_deref()
101 .and_then(sanitize_basename)
102 .unwrap_or(fallback);
103 if let Err(err) = std::fs::rename(&part, &final_name) {
104 let _ = std::fs::remove_file(&part); // best-effort
105 return Err(CoreError::Internal(format!(
106 "cannot finalize backup {final_name}: {err}"
107 )));
108 }
109 Ok(BackupDownloadResult {
110 file: final_name,
111 r#type: wire_type,
112 })
113}
114
115/// `ign backup restore` — thin orchestration over the 04-04 trait
116/// method: usage-class file pre-checks (the `rig restore` shape:
117/// exists + regular + non-empty, exit 2 BEFORE any network work),
118/// then the raw octet-stream POST. The 2xx is ACCEPTANCE; the
119/// gateway restarts after answering (README-documented).
120pub async fn backup_restore(
121 api: &dyn GatewayApi,
122 gwbk: &Path,
123) -> Result<BackupRestoreResult, CoreError> {
124 let meta = std::fs::metadata(gwbk).map_err(|_| CoreError::InvalidInput {
125 reason: format!("gwbk file {} not found", gwbk.display()),
126 })?;
127 if !meta.is_file() {
128 return Err(CoreError::InvalidInput {
129 reason: format!("gwbk file {} is not a regular file", gwbk.display()),
130 });
131 }
132 if meta.len() == 0 {
133 return Err(CoreError::InvalidInput {
134 reason: format!("gwbk file {} is empty", gwbk.display()),
135 });
136 }
137
138 api.backup_restore(gwbk).await?;
139 Ok(BackupRestoreResult { restored: true })
140}
141
142#[cfg(test)]
143mod tests {
144 use super::{safe_stem, sanitize_basename};
145
146 /// The basename sanitizer never lets a separator through (the
147 /// project-export defense, backup edition).
148 #[test]
149 fn basename_sanitizer_strips_separators() {
150 assert_eq!(
151 sanitize_basename("backup.gwbk").as_deref(),
152 Some("backup.gwbk")
153 );
154 assert_eq!(
155 sanitize_basename("../../etc/passwd").as_deref(),
156 Some(".._.._etc_passwd")
157 );
158 assert_eq!(sanitize_basename("a\\b.gwbk").as_deref(), Some("a_b.gwbk"));
159 assert_eq!(sanitize_basename(" "), None, "blank names nothing");
160 }
161
162 /// The fallback stem sanitizes the same way.
163 #[test]
164 fn fallback_stem_sanitizes() {
165 assert_eq!(safe_stem("gw/dev"), "gw_dev");
166 assert_eq!(safe_stem("plain"), "plain");
167 }
168}