forest/tool/subcommands/
backup_cmd.rs

1// Copyright 2019-2025 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use anyhow::bail;
5use clap::Subcommand;
6use std::{
7    fs::File,
8    path::{Path, PathBuf},
9};
10
11use crate::{cli_shared::read_config, networks::NetworkChain};
12
13#[derive(Subcommand)]
14pub enum BackupCommands {
15    /// Create a backup of the node. By default, only the peer-to-peer key-pair and key-store are backed up.
16    /// The node must be offline.
17    Create {
18        /// Path to the output backup file if not using the default
19        #[arg(long)]
20        backup_file: Option<PathBuf>,
21        /// Backup everything from the Forest data directory. This will override other options.
22        #[arg(long)]
23        all: bool,
24        /// Disables backing up the key-pair
25        #[arg(long)]
26        no_keypair: bool,
27        /// Disables backing up the key-store
28        #[arg(long)]
29        no_keystore: bool,
30        /// Backs up the blockstore for the specified chain. If not provided, it will not be backed up.
31        #[arg(long)]
32        backup_chain: Option<NetworkChain>,
33        /// Include proof parameters in the backup
34        #[arg(long)]
35        include_proof_params: bool,
36        /// Optional TOML file containing forest daemon configuration. If not provided, the default configuration will be used.
37        #[arg(short, long)]
38        daemon_config: Option<PathBuf>,
39    },
40    /// Restore a backup of the node from a file. The node must be offline.
41    Restore {
42        /// Path to the backup file
43        backup_file: PathBuf,
44        /// Optional TOML file containing forest daemon configuration. If not provided, the default configuration will be used.
45        #[arg(short, long)]
46        daemon_config: Option<PathBuf>,
47        /// Force restore even if files already exist
48        /// WARNING: This will overwrite existing files.
49        #[arg(long)]
50        force: bool,
51    },
52}
53
54impl BackupCommands {
55    pub fn run(self) -> anyhow::Result<()> {
56        match self {
57            BackupCommands::Create {
58                backup_file,
59                all,
60                no_keypair,
61                no_keystore,
62                backup_chain,
63                include_proof_params,
64                daemon_config,
65            } => {
66                let (_, config) = read_config(daemon_config.as_ref(), backup_chain.clone())?;
67
68                let data_dir = &config.client.data_dir;
69
70                let backup_entries = if all {
71                    std::fs::read_dir(data_dir)?
72                        .filter_map(Result::ok)
73                        .map(|e| e.path())
74                        .collect()
75                } else {
76                    validate_and_add_entries(
77                        data_dir,
78                        no_keypair,
79                        no_keystore,
80                        backup_chain,
81                        include_proof_params,
82                    )?
83                };
84
85                let backup_file_path = if let Some(backup_file) = backup_file {
86                    backup_file
87                } else {
88                    let path = PathBuf::from(format!(
89                        "forest-backup-{}.tar",
90                        chrono::Utc::now().format("%Y-%m-%d_%H-%M-%S")
91                    ));
92                    if path.exists() {
93                        bail!("Backup file already exists at {}", path.display());
94                    }
95                    path
96                };
97
98                archive_entries(data_dir, backup_entries, &backup_file_path)?;
99                println!("Backup complete: {}", backup_file_path.display());
100
101                Ok(())
102            }
103            BackupCommands::Restore {
104                backup_file,
105                daemon_config,
106                force,
107            } => {
108                let (_, config) = read_config(daemon_config.as_ref(), None)?;
109                let data_dir = &config.client.data_dir;
110
111                extract_entries(data_dir, &backup_file, force)?;
112                println!("Restore complete");
113
114                Ok(())
115            }
116        }
117    }
118}
119
120fn extract_entries(data_dir: &Path, backup_file: &Path, force: bool) -> anyhow::Result<()> {
121    let backup_file = File::open(backup_file)?;
122    let mut archive = tar::Archive::new(backup_file);
123    for file in archive.entries()? {
124        let mut file = file?;
125        let path = file.path()?;
126        let path = data_dir.join(path);
127        if path.exists() && !force {
128            bail!(
129                "File already exists at {}. Use --force to overwrite.",
130                path.display()
131            );
132        }
133        if let Some(parent) = path.parent() {
134            std::fs::create_dir_all(parent)?;
135        }
136        println!("Restoring {}", path.display());
137        file.unpack(path)?;
138    }
139
140    Ok(())
141}
142
143fn archive_entries(
144    data_dir: &PathBuf,
145    backup_entries: Vec<PathBuf>,
146    backup_file_path: &Path,
147) -> anyhow::Result<()> {
148    let backup_file = File::create(backup_file_path)?;
149    let mut archive = tar::Builder::new(backup_file);
150    for entry in backup_entries {
151        let entry_canonicalized = entry.canonicalize()?;
152        let name = entry.strip_prefix(data_dir)?;
153
154        println!("Adding {} to backup", entry_canonicalized.display());
155        if entry_canonicalized.is_dir() {
156            archive.append_dir_all(name, entry_canonicalized)?;
157        } else {
158            archive.append_path_with_name(entry_canonicalized, name)?;
159        }
160    }
161    archive.into_inner()?;
162    Ok(())
163}
164
165fn validate_and_add_entries(
166    data_dir: &Path,
167    no_keypair: bool,
168    no_keystore: bool,
169    backup_chain: Option<NetworkChain>,
170    include_proof_params: bool,
171) -> anyhow::Result<Vec<PathBuf>> {
172    if no_keypair && no_keystore && backup_chain.is_none() && !include_proof_params {
173        bail!("Nothing to backup!");
174    }
175
176    let mut valid = true;
177    let mut backup_entries = vec![];
178
179    if !no_keypair {
180        let keypair_path = data_dir.join("libp2p").join("keypair");
181        if keypair_path.exists() {
182            backup_entries.push(keypair_path);
183        } else {
184            println!("Keypair not found at {}", keypair_path.display());
185            valid = false;
186        }
187    }
188
189    if !no_keystore {
190        let mut any_keystore_found = false;
191        for keystore in ["keystore.json", "keystore"].iter() {
192            let keystore_path = data_dir.join(keystore);
193            if keystore_path.exists() {
194                backup_entries.push(keystore_path);
195                any_keystore_found = true;
196            }
197        }
198        if !any_keystore_found {
199            println!("No keystore found at {}", data_dir.display());
200            valid = false;
201        }
202    }
203
204    if let Some(chain) = backup_chain {
205        let chain_path = data_dir.join(chain.to_string());
206        if chain_path.exists() {
207            backup_entries.push(chain_path);
208        } else {
209            println!("Chain data not found at {}", chain_path.display());
210            valid = false;
211        }
212    }
213
214    if include_proof_params {
215        let proof_params_path = data_dir.join("filecoin-proof-parameters");
216        if proof_params_path.exists() {
217            backup_entries.push(proof_params_path);
218        } else {
219            println!(
220                "Proof parameters not found at {}",
221                proof_params_path.display()
222            );
223            valid = false;
224        }
225    }
226
227    if !valid {
228        bail!("Backup aborted. Some files were not found.");
229    }
230
231    Ok(backup_entries)
232}
233
234#[cfg(test)]
235mod test {
236    use itertools::Itertools;
237    use tempfile::TempDir;
238    use walkdir::WalkDir;
239
240    use super::*;
241
242    #[test]
243    fn validate_and_add_entries_no_entries() {
244        let data_dir = PathBuf::from("test");
245        let result = validate_and_add_entries(
246            &data_dir, true,  // no_keypair
247            true,  // no_keystore
248            None,  // no backup_chain
249            false, // no include_proof_params
250        );
251        assert!(result.is_err());
252    }
253
254    fn create_test_data() -> (TempDir, Vec<PathBuf>) {
255        let temp_dir = tempfile::tempdir().unwrap();
256        let data_dir = temp_dir.path().to_path_buf();
257        let mut entries = vec![
258            data_dir.join("libp2p").join("keypair"),
259            data_dir.join("keystore.json"),
260            data_dir.join("keystore"),
261        ];
262
263        entries.iter().for_each(|entry| {
264            std::fs::create_dir_all(entry.parent().unwrap()).unwrap();
265            File::create(entry).unwrap();
266        });
267
268        let chain_path = data_dir.join("calibnet");
269        std::fs::create_dir_all(&chain_path).unwrap();
270        entries.push(chain_path);
271
272        let proof_params_path = data_dir.join("filecoin-proof-parameters");
273        std::fs::create_dir_all(&proof_params_path).unwrap();
274        entries.push(proof_params_path);
275
276        (temp_dir, entries)
277    }
278
279    #[test]
280    fn validate_and_add_entries_all() {
281        let (temp_dir, entries) = create_test_data();
282        let data_dir = temp_dir.path().to_path_buf();
283
284        let result = validate_and_add_entries(
285            &data_dir,
286            false,                        // include keypair
287            false,                        // include keystore
288            Some(NetworkChain::Calibnet), // backup the chain
289            true,                         // include proof_params
290        );
291
292        let backup_entries = result.unwrap();
293        itertools::assert_equal(entries.iter().sorted(), backup_entries.iter().sorted());
294    }
295
296    #[test]
297    fn validate_and_add_entries_no_keypair() {
298        let (temp_dir, _) = create_test_data();
299        let data_dir = temp_dir.path().to_path_buf();
300
301        std::fs::remove_file(data_dir.join("libp2p").join("keypair")).unwrap();
302
303        let result = validate_and_add_entries(
304            &data_dir,
305            false,                        // include keypair
306            false,                        // include keystore
307            Some(NetworkChain::Calibnet), // backup the chain
308            true,                         // include proof_params
309        );
310        assert!(result.is_err());
311
312        let result = validate_and_add_entries(
313            &data_dir,
314            true,                         // exclude keypair
315            false,                        // include keystore
316            Some(NetworkChain::Calibnet), // backup the chain
317            true,                         // include proof_params
318        );
319        assert!(result.is_ok());
320    }
321
322    #[test]
323    fn validate_and_add_entries_no_keystore() {
324        let (temp_dir, _) = create_test_data();
325        let data_dir = temp_dir.path().to_path_buf();
326
327        std::fs::remove_file(data_dir.join("keystore.json")).unwrap();
328        let result = validate_and_add_entries(
329            &data_dir,
330            false,                        // include keypair
331            false,                        // include keystore
332            Some(NetworkChain::Calibnet), // backup the chain
333            true,                         // include proof_params
334        );
335        // it should be fine - there is also the encrypted keystore
336        assert!(result.is_ok());
337
338        std::fs::remove_file(data_dir.join("keystore")).unwrap();
339        let result = validate_and_add_entries(
340            &data_dir,
341            false,                        // include keypair
342            false,                        // include keystore
343            Some(NetworkChain::Calibnet), // backup the chain
344            true,                         // include proof_params
345        );
346        assert!(result.is_err());
347
348        let result = validate_and_add_entries(
349            &data_dir,
350            false,                        // include keypair
351            true,                         // exclude keystore
352            Some(NetworkChain::Calibnet), // backup the chain
353            true,                         // include proof_params
354        );
355        assert!(result.is_ok());
356    }
357
358    #[test]
359    fn validate_and_add_entries_proof_params() {
360        let (temp_dir, _) = create_test_data();
361        let data_dir = temp_dir.path().to_path_buf();
362
363        std::fs::remove_dir_all(data_dir.join("filecoin-proof-parameters")).unwrap();
364        let result = validate_and_add_entries(
365            &data_dir,
366            false,                        // include keypair
367            false,                        // include keystore
368            Some(NetworkChain::Calibnet), // backup the chain
369            true,                         // include proof_params
370        );
371        assert!(result.is_err());
372
373        let result = validate_and_add_entries(
374            &data_dir,
375            false,                        // include keypair
376            false,                        // include keystore
377            Some(NetworkChain::Calibnet), // backup the chain
378            false,                        // exclude proof_params
379        );
380        assert!(result.is_ok());
381    }
382
383    #[test]
384    fn validate_and_add_entries_no_chain() {
385        let (temp_dir, _) = create_test_data();
386        let data_dir = temp_dir.path().to_path_buf();
387
388        std::fs::remove_dir_all(data_dir.join("calibnet")).unwrap();
389        let result = validate_and_add_entries(
390            &data_dir,
391            false,                        // include keypair
392            false,                        // include keystore
393            Some(NetworkChain::Calibnet), // backup the chain
394            true,                         // include proof_params
395        );
396        assert!(result.is_err());
397
398        let result = validate_and_add_entries(
399            &data_dir, false, // include keypair
400            false, // include keystore
401            None,  // no backup_chain
402            true,  // include proof_params
403        );
404        assert!(result.is_ok());
405    }
406
407    #[test]
408    fn archive_extract_roundtrip() {
409        let (temp_dir, entries) = create_test_data();
410        let data_dir = temp_dir.path().to_path_buf();
411
412        let backup_file = tempfile::Builder::new().suffix(".tar").tempfile().unwrap();
413        archive_entries(&data_dir, entries.clone(), backup_file.path()).unwrap();
414
415        let restore_dir = tempfile::tempdir().unwrap();
416        extract_entries(restore_dir.path(), backup_file.path(), true).unwrap();
417
418        // get all entries recursively
419        let get_entries_recurse = |dir| {
420            WalkDir::new(dir)
421                .into_iter()
422                .filter_map(Result::ok)
423                .map(|entry| entry.path().strip_prefix(dir).unwrap().to_path_buf())
424                .sorted()
425                .collect::<Vec<PathBuf>>()
426        };
427        let restored = get_entries_recurse(restore_dir.path());
428        let original = get_entries_recurse(&data_dir);
429
430        assert!(restored.len() > entries.len());
431        itertools::assert_equal(original.iter(), restored.iter());
432    }
433}