systemprompt_cli/commands/cloud/backup/
extract.rs1use std::fs;
11use std::path::Path;
12
13use anyhow::{Result, bail};
14use flate2::read::GzDecoder;
15use tar::Archive;
16
17const ALLOWED_DIRS: &[&str] = &[
18 "agents", "skills", "content", "mcp", "ai", "config", "profiles",
19];
20
21pub fn extract_tarball(data: &[u8], target: &Path) -> Result<usize> {
22 let decoder = GzDecoder::new(data);
23 let mut archive = Archive::new(decoder);
24 let mut count = 0;
25
26 let canonical_target = target.canonicalize()?;
27
28 for entry in archive.entries()? {
29 let mut entry = entry?;
30
31 let entry_type = entry.header().entry_type();
32 if !(entry_type.is_file() || entry_type.is_dir()) {
33 bail!(
34 "disallowed entry type {:?} in tarball: {}",
35 entry_type,
36 entry.path()?.to_string_lossy()
37 );
38 }
39
40 let entry_path = entry.path()?.into_owned();
41 let entry_path_str = entry_path.to_string_lossy();
42
43 if entry_path.is_absolute()
44 || entry_path.components().any(|c| {
45 matches!(
46 c,
47 std::path::Component::ParentDir | std::path::Component::RootDir
48 )
49 })
50 {
51 bail!("invalid path in tarball: {entry_path_str}");
52 }
53
54 let first_component = entry_path
55 .components()
56 .find_map(|c| match c {
57 std::path::Component::Normal(s) => s.to_str(),
58 _ => None,
59 })
60 .unwrap_or("");
61 if !ALLOWED_DIRS.contains(&first_component) {
62 bail!("path not in allowed top-level directory: {entry_path_str}");
63 }
64
65 let dest_path = canonical_target.join(&entry_path);
66
67 if !dest_path.starts_with(&canonical_target) {
68 bail!("path escapes target directory: {entry_path_str}");
69 }
70
71 if let Some(parent) = dest_path.parent() {
72 fs::create_dir_all(parent)?;
73 }
74
75 entry.unpack(&dest_path)?;
76 count += 1;
77 }
78
79 Ok(count)
80}