use super::PackageArtifact;
use crate::core::config::ResolvedCrateConfig;
use crate::publish::platform::{Os, RustTarget};
use anyhow::{Result, bail};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TsMode {
Nts,
Ts,
}
impl TsMode {
pub fn as_short(&self) -> &'static str {
match self {
Self::Nts => "nts",
Self::Ts => "ts",
}
}
fn as_unix_suffix(&self) -> &'static str {
match self {
Self::Nts => "nts",
Self::Ts => "zts",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s.to_ascii_lowercase().as_str() {
"nts" => Ok(Self::Nts),
"ts" | "zts" => Ok(Self::Ts),
other => bail!("unknown ts-mode '{other}': expected 'nts' or 'ts'"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
pub enum DebugMode {
#[default]
NoDebug,
Debug,
}
impl DebugMode {
pub fn as_tag(&self) -> &'static str {
match self {
Self::NoDebug => "nodebug",
Self::Debug => "debug",
}
}
}
pub struct PiePackageOptions<'a> {
pub php_version: &'a str,
pub ts_mode: TsMode,
pub debug_mode: DebugMode,
pub libc_override: Option<&'a str>,
pub windows_compiler: Option<&'a str>,
}
pub fn package_php(
config: &ResolvedCrateConfig,
target: &RustTarget,
workspace_root: &Path,
output_dir: &Path,
version: &str,
options: &PiePackageOptions<'_>,
) -> Result<PackageArtifact> {
let ext_name = config.php_extension_name();
let cargo_lib_stem = crate::publish::crate_name_from_output(config, crate::core::config::extras::Language::Php)
.map(|n| n.replace('-', "_"))
.unwrap_or_else(|| ext_name.clone());
let archive_name = pie_archive_name(&ext_name, version, target, options)?;
let archive_path = output_dir.join(&archive_name);
let staging = output_dir.join(format!("_pie_stage_{ext_name}_{}", target.triple));
if staging.exists() {
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
if target.os == Os::Windows {
let cargo_dll_name = format!("{cargo_lib_stem}.dll");
let dll_src = find_php_ext(workspace_root, target, &cargo_dll_name)?;
let staged_name = format!("{ext_name}.dll");
fs::copy(&dll_src, staging.join(&staged_name))?;
create_zip(&staging, &archive_path)?;
} else {
let cargo_lib_file = target.shared_lib_name(&cargo_lib_stem);
let lib_src = find_php_ext(workspace_root, target, &cargo_lib_file)?;
let staged_name = format!("{ext_name}.so");
fs::copy(&lib_src, staging.join(&staged_name))?;
super::create_tar_gz_flat(&staging, &archive_path)?;
}
fs::remove_dir_all(&staging).ok();
let checksum = sha256_file(&archive_path)?;
let sidecar_path = output_dir.join(format!("{archive_name}.sha256"));
fs::write(&sidecar_path, format!("{checksum} {archive_name}\n"))?;
Ok(PackageArtifact {
path: archive_path,
name: archive_name,
checksum: Some(checksum),
})
}
fn pie_archive_name(
ext_name: &str,
version: &str,
target: &RustTarget,
options: &PiePackageOptions<'_>,
) -> Result<String> {
let lower = |s: &str| s.to_lowercase();
let ver_prefixed = if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
};
if target.os == Os::Windows {
let compiler = options
.windows_compiler
.ok_or_else(|| anyhow::anyhow!("windows PHP packaging requires --windows-compiler (e.g. vs17)"))?;
Ok(lower(&format!(
"php_{ext}-{ver}-{php}-{ts}-{cc}-{arch}.zip",
ext = ext_name,
ver = ver_prefixed,
php = options.php_version,
ts = options.ts_mode.as_short(),
cc = compiler,
arch = target.pie_arch()?,
)))
} else {
let libc = options
.libc_override
.map(|s| s.to_string())
.map_or_else(|| target.pie_libc().map(|s| s.to_string()), Ok)?;
Ok(lower(&format!(
"php_{ext}-{ver}_php{php}-{arch}-{os}-{libc}-{ts}.tgz",
ext = ext_name,
ver = ver_prefixed,
php = options.php_version,
arch = target.pie_arch()?,
os = target.pie_os_family()?,
libc = libc,
ts = options.ts_mode.as_unix_suffix(),
)))
}
}
fn find_php_ext(workspace_root: &Path, target: &RustTarget, lib_file: &str) -> Result<PathBuf> {
let cross = workspace_root
.join("target")
.join(&target.triple)
.join("release")
.join(lib_file);
if cross.exists() {
return Ok(cross);
}
let native = workspace_root.join("target/release").join(lib_file);
if native.exists() {
return Ok(native);
}
bail!(
"PHP extension '{lib_file}' not found in target/{}/release/ or target/release/",
target.triple
)
}
fn create_zip(staging_dir: &Path, output_path: &Path) -> Result<()> {
use std::io::Write;
let file = fs::File::create(output_path)?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::FileOptions::<()>::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
for entry in fs::read_dir(staging_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let file_name = entry.file_name().to_string_lossy().into_owned();
let mut source = fs::File::open(&path)?;
let mut buf = Vec::new();
source.read_to_end(&mut buf)?;
zip.start_file(&file_name, options)?;
zip.write_all(&buf)?;
}
zip.finish()?;
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
use anyhow::Context as _;
use sha2::{Digest, Sha256};
let mut file = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 65536];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let digest = hasher.finalize();
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest.iter() {
use std::fmt::Write as _;
write!(&mut hex, "{byte:02x}").expect("writing to String never fails");
}
Ok(hex)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::publish::platform::RustTarget;
use tempfile::TempDir;
fn make_config(name: &str) -> crate::core::config::ResolvedCrateConfig {
let cfg: crate::core::config::NewAlefConfig = toml::from_str(&format!(
r#"
[workspace]
languages = ["php"]
[[crates]]
name = "{name}"
sources = ["src/lib.rs"]
"#
))
.unwrap();
cfg.resolve().unwrap().remove(0)
}
fn nts_options(php_version: &str) -> PiePackageOptions<'_> {
PiePackageOptions {
php_version,
ts_mode: TsMode::Nts,
debug_mode: DebugMode::NoDebug,
libc_override: None,
windows_compiler: None,
}
}
#[test]
fn ts_mode_from_str_nts() {
assert_eq!(TsMode::parse("nts").unwrap(), TsMode::Nts);
}
#[test]
fn ts_mode_from_str_ts() {
assert_eq!(TsMode::parse("ts").unwrap(), TsMode::Ts);
}
#[test]
fn ts_mode_from_str_zts_accepted() {
assert_eq!(TsMode::parse("zts").unwrap(), TsMode::Ts);
}
#[test]
fn ts_mode_from_str_case_insensitive() {
assert_eq!(TsMode::parse("NTS").unwrap(), TsMode::Nts);
assert_eq!(TsMode::parse("TS").unwrap(), TsMode::Ts);
}
#[test]
fn ts_mode_from_str_unknown_errors() {
assert!(TsMode::parse("thread").is_err());
}
#[test]
fn pie_filename_linux_x86_64_glibc_nts() {
let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
let opts = nts_options("8.5");
let name = pie_archive_name("demo_render", "3.4.0", &target, &opts).unwrap();
assert_eq!(name, "php_demo_render-v3.4.0_php8.5-x86_64-linux-glibc-nts.tgz");
}
#[test]
fn pie_filename_linux_aarch64_musl_zts() {
let target = RustTarget::parse("aarch64-unknown-linux-musl").unwrap();
let opts = PiePackageOptions {
php_version: "8.4",
ts_mode: TsMode::Ts,
debug_mode: DebugMode::NoDebug,
libc_override: None,
windows_compiler: None,
};
let name = pie_archive_name("myext", "1.0.0", &target, &opts).unwrap();
assert_eq!(name, "php_myext-v1.0.0_php8.4-arm64-linux-musl-zts.tgz");
}
#[test]
fn pie_filename_macos_arm64_nts() {
let target = RustTarget::parse("aarch64-apple-darwin").unwrap();
let opts = nts_options("8.5");
let name = pie_archive_name("demo_render", "3.4.0-rc.22", &target, &opts).unwrap();
assert_eq!(name, "php_demo_render-v3.4.0-rc.22_php8.5-arm64-darwin-bsdlibc-nts.tgz");
}
#[test]
fn pie_filename_windows_x86_64_vs17_nts() {
let target = RustTarget::parse("x86_64-pc-windows-msvc").unwrap();
let opts = PiePackageOptions {
php_version: "8.5",
ts_mode: TsMode::Nts,
debug_mode: DebugMode::NoDebug,
libc_override: None,
windows_compiler: Some("vs17"),
};
let name = pie_archive_name("demo_render", "3.4.0", &target, &opts).unwrap();
assert_eq!(name, "php_demo_render-v3.4.0-8.5-nts-vs17-x86_64.zip");
}
#[test]
fn pie_filename_accepts_version_with_v_prefix() {
let target = RustTarget::parse("aarch64-apple-darwin").unwrap();
let opts = nts_options("8.5");
let name = pie_archive_name("demo_render", "v1.2.3", &target, &opts).unwrap();
assert_eq!(name, "php_demo_render-v1.2.3_php8.5-arm64-darwin-bsdlibc-nts.tgz");
}
#[test]
fn pie_filename_windows_missing_compiler_errors() {
let target = RustTarget::parse("x86_64-pc-windows-msvc").unwrap();
let opts = nts_options("8.5");
let result = pie_archive_name("myext", "1.0.0", &target, &opts);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("windows-compiler"),
"error message should mention --windows-compiler, got: {msg}"
);
}
#[test]
fn pie_filename_lowercase_invariant() {
let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
let opts = nts_options("8.5");
let name = pie_archive_name("MyExt", "1.0.0", &target, &opts).unwrap();
assert_eq!(name, name.to_lowercase(), "archive name must be all-lowercase");
}
#[test]
fn pie_libc_override_wins() {
let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
let opts = PiePackageOptions {
php_version: "8.4",
ts_mode: TsMode::Nts,
debug_mode: DebugMode::NoDebug,
libc_override: Some("musl"),
windows_compiler: None,
};
let name = pie_archive_name("ext", "1.0.0", &target, &opts).unwrap();
assert!(name.contains("-musl-"), "expected musl in name, got: {name}");
}
#[test]
fn pie_filename_no_debug_segment() {
let target = RustTarget::parse("aarch64-apple-darwin").unwrap();
let opts = PiePackageOptions {
php_version: "8.4",
ts_mode: TsMode::Nts,
debug_mode: DebugMode::NoDebug,
libc_override: None,
windows_compiler: None,
};
let name = pie_archive_name("demo_client", "1.4.0-rc.32", &target, &opts).unwrap();
assert_eq!(name, "php_demo_client-v1.4.0-rc.32_php8.4-arm64-darwin-bsdlibc-nts.tgz");
}
#[test]
fn pie_archive_contains_only_extension_at_root() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let output_dir = tmp.path().join("dist");
fs::create_dir_all(&output_dir).unwrap();
let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
let release_dir = workspace.join("target/x86_64-unknown-linux-gnu/release");
fs::create_dir_all(&release_dir).unwrap();
fs::write(release_dir.join("libdemo_render.so"), b"ELF fake so content").unwrap();
let config = make_config("demo-render");
let opts = nts_options("8.4");
let artifact = package_php(&config, &target, &workspace, &output_dir, "3.4.0", &opts).unwrap();
assert!(artifact.path.exists(), "archive file must exist");
assert!(artifact.checksum.is_some(), "checksum must be set");
let output = std::process::Command::new("tar")
.arg("-tzf")
.arg(&artifact.path)
.output()
.expect("tar must be available");
let listing = String::from_utf8_lossy(&output.stdout);
let entries: Vec<&str> = listing
.lines()
.filter(|l| !l.ends_with('/'))
.collect();
assert_eq!(
entries.len(),
1,
"expected exactly one file in archive, got: {entries:?}"
);
assert!(
entries[0].ends_with("demo_render.so"),
"expected demo_render.so at archive root, got: {}",
entries[0]
);
let sidecar = output_dir.join(format!("{}.sha256", artifact.name));
assert!(sidecar.exists(), "SHA-256 sidecar must be written");
}
#[test]
fn pie_archive_macos_uses_dylib_extension() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let output_dir = tmp.path().join("dist");
fs::create_dir_all(&output_dir).unwrap();
let target = RustTarget::parse("aarch64-apple-darwin").unwrap();
let release_dir = workspace.join("target/aarch64-apple-darwin/release");
fs::create_dir_all(&release_dir).unwrap();
fs::write(release_dir.join("libdemo_render.dylib"), b"Mach-O fake dylib content").unwrap();
let config = make_config("demo-render");
let opts = nts_options("8.4");
let artifact = package_php(&config, &target, &workspace, &output_dir, "3.4.0", &opts).unwrap();
assert!(artifact.path.exists(), "archive file must exist");
let output = std::process::Command::new("tar")
.arg("-tzf")
.arg(&artifact.path)
.output()
.expect("tar must be available");
let listing = String::from_utf8_lossy(&output.stdout);
let entries: Vec<&str> = listing.lines().filter(|l| !l.ends_with('/')).collect();
assert_eq!(
entries.len(),
1,
"expected exactly one file in macOS archive, got: {entries:?}"
);
assert!(
entries[0].ends_with("demo_render.dylib"),
"expected demo_render.dylib on macOS at archive root, got: {}",
entries[0]
);
}
}