Skip to main content

systemprompt_cli/commands/cloud/backup/
extract.rs

1//! Guarded extraction of the downloaded services tarball.
2//!
3//! Hardened against path-traversal: symlinks, absolute paths, `..`
4//! components, and entries outside the allowed top-level directories are all
5//! rejected before anything touches disk.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use 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}