const DANGEROUS_PATH_SUFFIXES: &[&str] = &[
"/plugins",
"/themes",
"/mu-plugins",
"/wp-content",
"/wp-content/uploads",
"/node_modules",
"/vendor",
"/packages",
"/extensions",
];
fn validate_deploy_target(install_dir: &str, base_path: &str, component_id: &str) -> Result<()> {
let normalized = install_dir.trim_end_matches('/');
let base_normalized = base_path.trim_end_matches('/');
if normalized == base_normalized {
return Err(Error::validation_invalid_argument(
"remotePath",
format!(
"Deploy target '{}' resolves to the project base_path — this would destroy the entire project. \
Set remote_path to the component's own subdirectory within the project",
install_dir
),
Some(install_dir.to_string()),
None,
));
}
for suffix in DANGEROUS_PATH_SUFFIXES {
if normalized.ends_with(suffix) {
return Err(Error::validation_invalid_argument(
"remotePath",
format!(
"Deploy target '{}' is a shared parent directory — deploying here would delete \
sibling components. Set remote_path to the component's own subdirectory \
(e.g., '{}/{}')",
install_dir, normalized, component_id
),
Some(install_dir.to_string()),
None,
));
}
}
Ok(())
}
fn deploy_via_git(
ssh_client: &SshClient,
remote_path: &str,
git_config: &component::GitDeployConfig,
component_version: Option<&str>,
) -> Result<DeployResult> {
let checkout_target = if let Some(ref pattern) = git_config.tag_pattern {
if let Some(ver) = component_version {
pattern.replace("{{version}}", ver)
} else {
git_config.branch.clone()
}
} else {
git_config.branch.clone()
};
log_status!(
"deploy:git",
"Fetching from {} in {}",
git_config.remote,
remote_path
);
let fetch_cmd = format!(
"cd {} && git fetch {} --tags",
shell::quote_path(remote_path),
shell::quote_arg(&git_config.remote),
);
let fetch_output = ssh_client.execute(&fetch_cmd);
if !fetch_output.success {
return Ok(DeployResult::failure(
fetch_output.exit_code,
format!("git fetch failed: {}", fetch_output.stderr),
));
}
let is_tag = git_config.tag_pattern.is_some() && component_version.is_some();
let checkout_cmd = if is_tag {
format!(
"cd {} && git checkout {}",
shell::quote_path(remote_path),
shell::quote_arg(&checkout_target),
)
} else {
format!(
"cd {} && git checkout {} && git pull {} {}",
shell::quote_path(remote_path),
shell::quote_arg(&checkout_target),
shell::quote_arg(&git_config.remote),
shell::quote_arg(&checkout_target),
)
};
log_status!("deploy:git", "Checking out {}", checkout_target);
let checkout_output = ssh_client.execute(&checkout_cmd);
if !checkout_output.success {
return Ok(DeployResult::failure(
checkout_output.exit_code,
format!("git checkout/pull failed: {}", checkout_output.stderr),
));
}
for cmd in &git_config.post_pull {
log_status!("deploy:git", "Running: {}", cmd);
let full_cmd = format!("cd {} && {}", shell::quote_path(remote_path), cmd);
let output = ssh_client.execute(&full_cmd);
if !output.success {
return Ok(DeployResult::failure(
output.exit_code,
format!("post-pull command failed ({}): {}", cmd, output.stderr),
));
}
}
log_status!("deploy:git", "Deploy complete for {}", remote_path);
Ok(DeployResult::success(0))
}
fn deploy_artifact(
ssh_client: &SshClient,
local_path: &Path,
remote_path: &str,
extract_command: Option<&str>,
verification: Option<&DeployVerification>,
remote_owner: Option<&str>,
) -> Result<DeployResult> {
if local_path.is_dir() {
let result = upload_directory(ssh_client, local_path, remote_path)?;
if !result.success {
return Ok(result);
}
} else {
let is_archive = local_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| matches!(ext, "zip" | "tar" | "gz" | "tgz"))
.unwrap_or(false);
if is_archive && extract_command.is_none() {
return Ok(DeployResult::failure(
1,
format!(
"Archive artifact '{}' requires an extractCommand. \
Add one with: homeboy component set <id> '{{\"extractCommand\": \"unzip -o {{artifact}} && rm {{artifact}}\"}}'",
local_path.display()
),
));
}
let deploy_defaults = defaults::load_defaults().deploy;
let artifact_prefix = &deploy_defaults.artifact_prefix;
let artifact_filename = local_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
Error::validation_invalid_argument(
"buildArtifact",
"Build artifact path must include a file name",
Some(local_path.display().to_string()),
None,
)
})?
.to_string();
let artifact_filename = format!("{}{}", artifact_prefix, artifact_filename);
let upload_path = if extract_command.is_some() {
format!("{}/{}", remote_path, artifact_filename)
} else {
let local_filename = local_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
Error::validation_invalid_argument(
"buildArtifact",
"Build artifact path must include a file name",
Some(local_path.display().to_string()),
None,
)
})?;
format!("{}/{}", remote_path, local_filename)
};
let mkdir_cmd = format!("mkdir -p {}", shell::quote_path(remote_path));
log_status!("deploy", "Creating directory: {}", remote_path);
let mkdir_output = ssh_client.execute(&mkdir_cmd);
if !mkdir_output.success {
return Ok(DeployResult::failure(
mkdir_output.exit_code,
format!("Failed to create remote directory: {}", mkdir_output.stderr),
));
}
let result = upload_file(ssh_client, local_path, &upload_path)?;
if !result.success {
return Ok(result);
}
if let Some(cmd_template) = extract_command {
let normalized_remote = remote_path.trim_end_matches('/');
let is_dangerous = DANGEROUS_PATH_SUFFIXES
.iter()
.any(|suffix| normalized_remote.ends_with(suffix));
if is_dangerous {
return Ok(DeployResult::failure(
1,
format!(
"Refusing to clean '{}' — it is a shared parent directory. \
This would delete sibling components. Fix the component's remote_path.",
remote_path
),
));
}
let clean_cmd = format!(
"cd {} && find . -mindepth 1 -maxdepth 1 ! -name {} -exec rm -rf {{}} +",
shell::quote_path(remote_path),
shell::quote_arg(&artifact_filename),
);
log_status!("deploy", "Cleaning target directory before extraction");
let clean_output = ssh_client.execute(&clean_cmd);
if !clean_output.success {
log_status!(
"deploy",
"Warning: failed to clean target directory: {}",
clean_output.stderr
);
}
let mut vars = HashMap::new();
vars.insert("artifact".to_string(), artifact_filename);
vars.insert("targetDir".to_string(), remote_path.to_string());
let rendered_cmd = render_extract_command(cmd_template, &vars);
let extract_cmd = format!("cd {} && {}", shell::quote_path(remote_path), rendered_cmd);
log_status!("deploy", "Extracting: {}", rendered_cmd);
let extract_output = ssh_client.execute(&extract_cmd);
if !extract_output.success {
let error_detail = if extract_output.stderr.is_empty() {
extract_output.stdout.clone()
} else {
extract_output.stderr.clone()
};
return Ok(DeployResult::failure(
extract_output.exit_code,
format!(
"Extract command failed (exit {}): {}",
extract_output.exit_code, error_detail
),
));
}
log_status!("deploy", "Fixing file permissions");
permissions::fix_deployed_permissions(ssh_client, remote_path, remote_owner)?;
}
}
if let Some((v, verify_cmd_template)) = verification
.as_ref()
.and_then(|v| v.verify_command.as_ref().map(|cmd| (v, cmd)))
{
let mut vars = HashMap::new();
vars.insert(
TemplateVars::TARGET_DIR.to_string(),
remote_path.to_string(),
);
let verify_cmd = render_map(verify_cmd_template, &vars);
let verify_output = ssh_client.execute(&verify_cmd);
if !verify_output.success || verify_output.stdout.trim().is_empty() {
let error_msg = v
.verify_error_message
.as_ref()
.map(|msg| render_map(msg, &vars))
.unwrap_or_else(|| format!("Deploy verification failed for {}", remote_path));
return Ok(DeployResult::failure(1, error_msg));
}
}
Ok(DeployResult::success(0))
}
fn render_extract_command(template: &str, vars: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in vars {
result = result.replace(&format!("{{{}}}", key), value);
}
result
}