Skip to main content

zoi_install/
create.rs

1//! App creation logic.
2
3use std::fs;
4use std::path::Path;
5
6use anyhow::{Result, anyhow};
7use colored::Colorize;
8use mlua::LuaSerdeExt;
9use tar::Archive;
10use tempfile::Builder;
11use zoi_core::{types, utils};
12use zoi_package as package;
13use zoi_plugins::PluginManager;
14use zoi_resolver as resolver;
15use zstd::stream::read::Decoder as ZstdDecoder;
16
17/// Resolves a template-relative destination without allowing the template to
18/// write outside the app directory.
19fn create_destination_path(
20    destination_dir: &Path,
21    relative_path: &str
22) -> Result<std::path::PathBuf> {
23    let relative_path = Path::new(relative_path);
24    if !utils::is_safe_path(destination_dir, relative_path) {
25        return Err(anyhow!(
26            "App template destination escapes the app directory: {}",
27            relative_path.display()
28        ));
29    }
30    utils::safe_join(destination_dir, relative_path)
31}
32
33/// Extracts an app from a ZPA archive to a destination directory.
34fn install_app_from_archive(
35    archive_path: &Path,
36    destination_dir: &Path
37) -> Result<()> {
38    println!(
39        "Extracting app to '{}'...",
40        destination_dir.display().to_string().cyan()
41    );
42    let file = fs::File::open(archive_path)?;
43    let decoder = ZstdDecoder::new(file)?;
44    let mut archive = Archive::new(decoder);
45
46    let temp_extract_dir =
47        Builder::new().prefix("zoi-create-extract-").tempdir()?;
48
49    archive.unpack(temp_extract_dir.path())?;
50
51    let manifest_path = temp_extract_dir.path().join("manifest.json");
52    if !manifest_path.exists() {
53        // Fallback to legacy format
54        let create_pkg_dir = temp_extract_dir.path().join("data/createpkgdir");
55        if !create_pkg_dir.exists() {
56            return Err(anyhow!(
57                "Archive is not a valid app package: missing 'manifest.json' \
58                 or legacy 'data/createpkgdir'."
59            ));
60        }
61        utils::copy_dir_all(&create_pkg_dir, destination_dir)?;
62        return Ok(());
63    }
64
65    // Pooled format
66    let content = fs::read_to_string(&manifest_path)?;
67    let pooled_manifest =
68        serde_json::from_str::<types::PooledZpaManifest>(&content)?;
69    let pool_dir = temp_extract_dir.path().join("pool");
70
71    // App templates usually use the "" sub-package and project scope
72    if let Some(sub_mapping) = pooled_manifest.mappings.get("")
73        && let Some(scope_mapping) =
74            sub_mapping.scopes.get(&types::Scope::Project)
75    {
76        for mapped_dir in &scope_mapping.dirs {
77            if let Some(rel) = mapped_dir.path.strip_prefix("${createpkgdir}/")
78            {
79                fs::create_dir_all(create_destination_path(
80                    destination_dir,
81                    rel
82                )?)?;
83            }
84        }
85        for mapped_file in &scope_mapping.files {
86            if let Some(rel) = mapped_file.dest.strip_prefix("${createpkgdir}/")
87            {
88                let dest_path = create_destination_path(destination_dir, rel)?;
89                if let Some(parent) = dest_path.parent() {
90                    fs::create_dir_all(parent)?;
91                }
92                let pool_file =
93                    utils::safe_join(&pool_dir, Path::new(&mapped_file.hash))?;
94                fs::copy(pool_file, &dest_path)?;
95            }
96        }
97        for mapped_link in &scope_mapping.symlinks {
98            if let Some(rel) = mapped_link.link.strip_prefix("${createpkgdir}/")
99            {
100                let dest_path = create_destination_path(destination_dir, rel)?;
101                if let Some(parent) = dest_path.parent() {
102                    fs::create_dir_all(parent)?;
103                }
104                utils::symlink_file(
105                    Path::new(&mapped_link.target),
106                    &dest_path
107                )?;
108            }
109        }
110    }
111
112    Ok(())
113}
114
115/// Runs the app creation process.
116///
117/// This involves resolving the package, building it as an app template,
118/// and then installing it to the target directory.
119///
120/// # Errors
121///
122/// Returns an error if:
123/// - The package cannot be resolved.
124/// - The package type is not 'app'.
125/// - The build process fails.
126/// - The installation to the target directory fails.
127pub fn run(
128    source: &str,
129    app_name: Option<String>,
130    yes: bool,
131    plugin_manager: Option<&PluginManager>
132) -> Result<()> {
133    let (pkg, _, _, pkg_lua_path, _, _, _) =
134        resolver::resolve::resolve_package_and_version(
135            source, None, false, false
136        )?;
137
138    if pkg.package_type != types::PackageType::App {
139        return Err(anyhow!(
140            "Package '{}' is not of type 'app'. Use 'zoi install' for \
141             packages and collections.",
142            pkg.name
143        ));
144    }
145
146    let mut pkg_val = None;
147    if let Some(pm) = plugin_manager {
148        let v = pm
149            .lua
150            .to_value(&pkg)
151            .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
152        pm.trigger_hook("on_pre_create", Some(&v.clone()))?;
153        pkg_val = Some(v);
154    }
155
156    let dest_name = app_name.unwrap_or_else(|| pkg.name.clone());
157    let app_dir = Path::new(&dest_name);
158
159    if app_dir.exists() {
160        if app_dir.is_dir() {
161            if fs::read_dir(app_dir)?.next().is_some() {
162                println!(
163                    "{}",
164                    format!(
165                        "Warning: Directory '{dest_name}' already exists and \
166                         is not empty."
167                    )
168                    .yellow()
169                );
170                if !utils::ask_for_confirmation("Do you want to continue?", yes)
171                {
172                    return Err(anyhow!("Operation aborted by user."));
173                }
174            }
175        } else {
176            return Err(anyhow!(
177                "A file with the name '{dest_name}' already exists."
178            ));
179        }
180    }
181
182    println!(
183        "Creating app '{}' using template '{}'...",
184        dest_name.cyan(),
185        pkg.name.green()
186    );
187
188    let build_dir = Builder::new().prefix("zoi-create-build-").tempdir()?;
189
190    package::build::run(
191        &pkg_lua_path,
192        Some("source"),
193        &[utils::get_platform()?],
194        None,
195        zoi_core::types::SignMode::Embed,
196        Some(build_dir.path()),
197        pkg.version.as_deref(),
198        None,
199        false,
200        "native",
201        None,
202        false,
203        false,
204        false
205    )?;
206
207    let archive_filename = format!(
208        "{}-{}-{}.zpa",
209        pkg.name,
210        pkg.version.as_deref().unwrap_or_default(),
211        utils::get_platform()?,
212    );
213    let archive_path = build_dir.path().join(archive_filename);
214
215    if !archive_path.exists() {
216        return Err(anyhow!("Build failed to produce an archive."));
217    }
218
219    install_app_from_archive(&archive_path, app_dir)?;
220
221    if let (Some(pm), Some(v)) = (plugin_manager, pkg_val) {
222        pm.trigger_hook_nonfatal("on_post_create", Some(&v));
223    }
224
225    println!("\n{}", "App created successfully.".green());
226
227    Ok(())
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn create_destination_should_reject_parent_traversal() {
236        let destination = Path::new("/tmp/zoi-create-test");
237
238        assert!(create_destination_path(destination, "../outside").is_err());
239    }
240}