1use 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
17fn 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
33fn 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 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 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 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
115pub 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}