Skip to main content

alef_publish/package/
php.rs

1//! PHP PIE binary package builder.
2//!
3//! Produces a single flat archive containing the compiled PHP extension,
4//! named according to the PIE convention so that `pie install <vendor>/<pkg>`
5//! resolves to a pre-built binary instead of compiling from source.
6//!
7//! **Unix archive** (`{ext}.so` at archive root, `.tgz`):
8//! ```text
9//! php_{ext}-{ver}_php{phpVer}-{arch}-{os}-{libc}-{ts}.tgz
10//! ```
11//!
12//! **Windows archive** (`{ext}.dll` at archive root, `.zip`):
13//! ```text
14//! php_{ext}-{ver}-{phpVer}-{ts}-{compiler}-{arch}.zip
15//! ```
16
17use super::PackageArtifact;
18use crate::platform::{Os, RustTarget};
19use alef_core::config::ResolvedCrateConfig;
20use anyhow::{Result, bail};
21use std::fs;
22use std::io::Read;
23use std::path::{Path, PathBuf};
24
25/// Thread-safety mode for the PHP extension binary.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum TsMode {
28    /// Non-thread-safe (NTS) — the common default.
29    Nts,
30    /// Zend Thread Safe (ZTS).
31    Ts,
32}
33
34impl TsMode {
35    /// Short tag used in PIE filenames: `"nts"` or `"ts"` (Unix) / `"nts"` or `"ts"` (Windows).
36    ///
37    /// Note: The Unix PIE convention uses `"zts"` for thread-safe mode.
38    pub fn as_short(&self) -> &'static str {
39        match self {
40            Self::Nts => "nts",
41            Self::Ts => "ts",
42        }
43    }
44
45    /// Unix-specific suffix used in the tarball filename: `"nts"` or `"zts"`.
46    fn as_unix_suffix(&self) -> &'static str {
47        match self {
48            Self::Nts => "nts",
49            Self::Ts => "zts",
50        }
51    }
52
53    /// Parse from a string (`"nts"` or `"ts"`, case-insensitive).
54    pub fn parse(s: &str) -> Result<Self> {
55        match s.to_ascii_lowercase().as_str() {
56            "nts" => Ok(Self::Nts),
57            "ts" | "zts" => Ok(Self::Ts),
58            other => bail!("unknown ts-mode '{other}': expected 'nts' or 'ts'"),
59        }
60    }
61}
62
63/// Options that control PIE-conventional PHP packaging.
64pub struct PiePackageOptions<'a> {
65    /// PHP minor version string, e.g. `"8.5"`. Required.
66    pub php_version: &'a str,
67    /// Thread-safety mode. Defaults to `Nts`.
68    pub ts_mode: TsMode,
69    /// Override the libc tag (e.g. `"musl"`). Auto-detected from the target triple when `None`.
70    pub libc_override: Option<&'a str>,
71    /// Windows compiler tag, e.g. `"vs17"`. Required when `target.os == Windows`.
72    pub windows_compiler: Option<&'a str>,
73}
74
75/// Package a PHP extension binary as a PIE-conventional archive.
76///
77/// Returns a single `PackageArtifact` whose `name` follows the PIE filename
78/// convention and whose `checksum` is set to the SHA-256 hex digest of the
79/// archive (written as `{archive_name}.sha256` next to the archive).
80pub fn package_php(
81    config: &ResolvedCrateConfig,
82    target: &RustTarget,
83    workspace_root: &Path,
84    output_dir: &Path,
85    version: &str,
86    options: &PiePackageOptions<'_>,
87) -> Result<PackageArtifact> {
88    // PHP extension name comes from `[php].extension_name` in alef.toml (which is
89    // also the value emitted into composer.json's `php-ext.extension-name`). PIE
90    // installs the binary using this name, so it MUST match composer.json — never
91    // derive from the crate name (which carries a `-php` binding suffix).
92    let ext_name = config.php_extension_name();
93
94    // Cargo's compiled artifact filename comes from the crate name, not the PHP
95    // extension name — for `html-to-markdown-php` cargo emits `html_to_markdown_php.{so,dylib,dll}`.
96    let cargo_lib_stem = crate::crate_name_from_output(config, alef_core::config::extras::Language::Php)
97        .map(|n| n.replace('-', "_"))
98        .unwrap_or_else(|| ext_name.clone());
99
100    let archive_name = pie_archive_name(&ext_name, version, target, options)?;
101    let archive_path = output_dir.join(&archive_name);
102
103    // Staging directory (cleaned up after archive creation).
104    let staging = output_dir.join(format!("_pie_stage_{ext_name}_{}", target.triple));
105    if staging.exists() {
106        fs::remove_dir_all(&staging)?;
107    }
108    fs::create_dir_all(&staging)?;
109
110    if target.os == Os::Windows {
111        // Locate the cargo-produced .dll and rename it to {ext_name}.dll inside the archive.
112        let cargo_dll_name = format!("{cargo_lib_stem}.dll");
113        let dll_src = find_php_ext(workspace_root, target, &cargo_dll_name)?;
114        let staged_name = format!("{ext_name}.dll");
115        fs::copy(&dll_src, staging.join(&staged_name))?;
116        create_zip(&staging, &archive_path)?;
117    } else {
118        // Locate cargo's .so/.dylib and rename to {ext_name}.so inside the archive.
119        // PIE always looks for {ext_name}.so on Unix — even on macOS where cargo emits .dylib.
120        let cargo_lib_file = target.shared_lib_name(&cargo_lib_stem);
121        let lib_src = find_php_ext(workspace_root, target, &cargo_lib_file)?;
122        let staged_name = format!("{ext_name}.so");
123        fs::copy(&lib_src, staging.join(&staged_name))?;
124        super::create_tar_gz(&staging, &archive_path)?;
125    }
126
127    fs::remove_dir_all(&staging).ok();
128
129    // Compute and write SHA-256 sidecar.
130    let checksum = sha256_file(&archive_path)?;
131    let sidecar_path = output_dir.join(format!("{archive_name}.sha256"));
132    fs::write(&sidecar_path, format!("{checksum}  {archive_name}\n"))?;
133
134    Ok(PackageArtifact {
135        path: archive_path,
136        name: archive_name,
137        checksum: Some(checksum),
138    })
139}
140
141/// Generate the PIE-conventional archive filename.
142///
143/// All components are lowercased per the PIE spec.
144fn pie_archive_name(
145    ext_name: &str,
146    version: &str,
147    target: &RustTarget,
148    options: &PiePackageOptions<'_>,
149) -> Result<String> {
150    let lower = |s: &str| s.to_lowercase();
151    if target.os == Os::Windows {
152        let compiler = options
153            .windows_compiler
154            .ok_or_else(|| anyhow::anyhow!("windows PHP packaging requires --windows-compiler (e.g. vs17)"))?;
155        Ok(lower(&format!(
156            "php_{ext}-{ver}-{php}-{ts}-{cc}-{arch}.zip",
157            ext = ext_name,
158            ver = version,
159            php = options.php_version,
160            ts = options.ts_mode.as_short(),
161            cc = compiler,
162            arch = target.pie_arch()?,
163        )))
164    } else {
165        let libc = options
166            .libc_override
167            .map(|s| s.to_string())
168            .map_or_else(|| target.pie_libc().map(|s| s.to_string()), Ok)?;
169        Ok(lower(&format!(
170            "php_{ext}-{ver}_php{php}-{arch}-{os}-{libc}-{ts}.tgz",
171            ext = ext_name,
172            ver = version,
173            php = options.php_version,
174            arch = target.pie_arch()?,
175            os = target.pie_os_family()?,
176            libc = libc,
177            ts = options.ts_mode.as_unix_suffix(),
178        )))
179    }
180}
181
182/// Locate the compiled PHP extension (`.so`, `.dylib`, or `.dll`).
183///
184/// Searches `target/{triple}/release/` then `target/release/`.
185fn find_php_ext(workspace_root: &Path, target: &RustTarget, lib_file: &str) -> Result<PathBuf> {
186    let cross = workspace_root
187        .join("target")
188        .join(&target.triple)
189        .join("release")
190        .join(lib_file);
191    if cross.exists() {
192        return Ok(cross);
193    }
194    let native = workspace_root.join("target/release").join(lib_file);
195    if native.exists() {
196        return Ok(native);
197    }
198    bail!(
199        "PHP extension '{lib_file}' not found in target/{}/release/ or target/release/",
200        target.triple
201    )
202}
203
204/// Create a zip archive from a staging directory.
205///
206/// Adds every file at the top level of `staging_dir` into the zip at the archive root.
207fn create_zip(staging_dir: &Path, output_path: &Path) -> Result<()> {
208    use std::io::Write;
209    let file = fs::File::create(output_path)?;
210    let mut zip = zip::ZipWriter::new(file);
211    let options = zip::write::FileOptions::<()>::default()
212        .compression_method(zip::CompressionMethod::Deflated)
213        .unix_permissions(0o644);
214
215    for entry in fs::read_dir(staging_dir)? {
216        let entry = entry?;
217        let path = entry.path();
218        if !path.is_file() {
219            continue;
220        }
221        let file_name = entry.file_name().to_string_lossy().into_owned();
222        let mut source = fs::File::open(&path)?;
223        let mut buf = Vec::new();
224        source.read_to_end(&mut buf)?;
225        zip.start_file(&file_name, options)?;
226        zip.write_all(&buf)?;
227    }
228    zip.finish()?;
229    Ok(())
230}
231
232/// Compute the SHA-256 hex digest of a file.
233fn sha256_file(path: &Path) -> Result<String> {
234    use anyhow::Context as _;
235    use sha2::{Digest, Sha256};
236    let mut file = fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
237    let mut hasher = Sha256::new();
238    let mut buf = [0u8; 65536];
239    loop {
240        let n = file.read(&mut buf)?;
241        if n == 0 {
242            break;
243        }
244        hasher.update(&buf[..n]);
245    }
246    let digest = hasher.finalize();
247    let mut hex = String::with_capacity(digest.len() * 2);
248    for byte in digest.iter() {
249        use std::fmt::Write as _;
250        write!(&mut hex, "{byte:02x}").expect("writing to String never fails");
251    }
252    Ok(hex)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::platform::RustTarget;
259    use tempfile::TempDir;
260
261    fn make_config(name: &str) -> alef_core::config::ResolvedCrateConfig {
262        let cfg: alef_core::config::NewAlefConfig = toml::from_str(&format!(
263            r#"
264[workspace]
265languages = ["php"]
266
267[[crates]]
268name = "{name}"
269sources = ["src/lib.rs"]
270"#
271        ))
272        .unwrap();
273        cfg.resolve().unwrap().remove(0)
274    }
275
276    fn nts_options(php_version: &str) -> PiePackageOptions<'_> {
277        PiePackageOptions {
278            php_version,
279            ts_mode: TsMode::Nts,
280            libc_override: None,
281            windows_compiler: None,
282        }
283    }
284
285    // --- TsMode ---
286
287    #[test]
288    fn ts_mode_from_str_nts() {
289        assert_eq!(TsMode::parse("nts").unwrap(), TsMode::Nts);
290    }
291
292    #[test]
293    fn ts_mode_from_str_ts() {
294        assert_eq!(TsMode::parse("ts").unwrap(), TsMode::Ts);
295    }
296
297    #[test]
298    fn ts_mode_from_str_zts_accepted() {
299        assert_eq!(TsMode::parse("zts").unwrap(), TsMode::Ts);
300    }
301
302    #[test]
303    fn ts_mode_from_str_case_insensitive() {
304        assert_eq!(TsMode::parse("NTS").unwrap(), TsMode::Nts);
305        assert_eq!(TsMode::parse("TS").unwrap(), TsMode::Ts);
306    }
307
308    #[test]
309    fn ts_mode_from_str_unknown_errors() {
310        assert!(TsMode::parse("thread").is_err());
311    }
312
313    // --- pie_archive_name ---
314
315    #[test]
316    fn pie_filename_linux_x86_64_glibc_nts() {
317        let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
318        let opts = nts_options("8.5");
319        let name = pie_archive_name("html_to_markdown", "3.4.0", &target, &opts).unwrap();
320        assert_eq!(name, "php_html_to_markdown-3.4.0_php8.5-x86_64-linux-glibc-nts.tgz");
321    }
322
323    #[test]
324    fn pie_filename_linux_aarch64_musl_zts() {
325        let target = RustTarget::parse("aarch64-unknown-linux-musl").unwrap();
326        let opts = PiePackageOptions {
327            php_version: "8.4",
328            ts_mode: TsMode::Ts,
329            libc_override: None,
330            windows_compiler: None,
331        };
332        let name = pie_archive_name("myext", "1.0.0", &target, &opts).unwrap();
333        assert_eq!(name, "php_myext-1.0.0_php8.4-arm64-linux-musl-zts.tgz");
334    }
335
336    #[test]
337    fn pie_filename_macos_arm64_nts() {
338        let target = RustTarget::parse("aarch64-apple-darwin").unwrap();
339        let opts = nts_options("8.5");
340        let name = pie_archive_name("html_to_markdown", "3.4.0-rc.22", &target, &opts).unwrap();
341        assert_eq!(
342            name,
343            "php_html_to_markdown-3.4.0-rc.22_php8.5-arm64-darwin-bsdlibc-nts.tgz"
344        );
345    }
346
347    #[test]
348    fn pie_filename_windows_x86_64_vs17_nts() {
349        let target = RustTarget::parse("x86_64-pc-windows-msvc").unwrap();
350        let opts = PiePackageOptions {
351            php_version: "8.5",
352            ts_mode: TsMode::Nts,
353            libc_override: None,
354            windows_compiler: Some("vs17"),
355        };
356        let name = pie_archive_name("html_to_markdown", "3.4.0", &target, &opts).unwrap();
357        assert_eq!(name, "php_html_to_markdown-3.4.0-8.5-nts-vs17-x86_64.zip");
358    }
359
360    #[test]
361    fn pie_filename_windows_missing_compiler_errors() {
362        let target = RustTarget::parse("x86_64-pc-windows-msvc").unwrap();
363        let opts = nts_options("8.5");
364        let result = pie_archive_name("myext", "1.0.0", &target, &opts);
365        assert!(result.is_err());
366        let msg = result.unwrap_err().to_string();
367        assert!(
368            msg.contains("windows-compiler"),
369            "error message should mention --windows-compiler, got: {msg}"
370        );
371    }
372
373    #[test]
374    fn pie_filename_lowercase_invariant() {
375        let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
376        let opts = nts_options("8.5");
377        // Uppercase ext_name should produce all-lowercase output.
378        let name = pie_archive_name("MyExt", "1.0.0", &target, &opts).unwrap();
379        assert_eq!(name, name.to_lowercase(), "archive name must be all-lowercase");
380    }
381
382    #[test]
383    fn pie_libc_override_wins() {
384        let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
385        let opts = PiePackageOptions {
386            php_version: "8.4",
387            ts_mode: TsMode::Nts,
388            libc_override: Some("musl"),
389            windows_compiler: None,
390        };
391        let name = pie_archive_name("ext", "1.0.0", &target, &opts).unwrap();
392        // Override should win over the auto-detected "glibc".
393        assert!(name.contains("-musl-"), "expected musl in name, got: {name}");
394    }
395
396    // --- Archive layout ---
397
398    #[test]
399    fn pie_archive_contains_only_extension_at_root() {
400        let tmp = TempDir::new().unwrap();
401        let workspace = tmp.path().join("workspace");
402        let output_dir = tmp.path().join("dist");
403        fs::create_dir_all(&output_dir).unwrap();
404
405        // Create a fake .so in the expected location.
406        let target = RustTarget::parse("x86_64-unknown-linux-gnu").unwrap();
407        let release_dir = workspace.join("target/x86_64-unknown-linux-gnu/release");
408        fs::create_dir_all(&release_dir).unwrap();
409        fs::write(release_dir.join("libhtml_to_markdown.so"), b"ELF fake so content").unwrap();
410
411        let config = make_config("html-to-markdown");
412        let opts = nts_options("8.4");
413        let artifact = package_php(&config, &target, &workspace, &output_dir, "3.4.0", &opts).unwrap();
414
415        // Verify the archive exists.
416        assert!(artifact.path.exists(), "archive file must exist");
417        assert!(artifact.checksum.is_some(), "checksum must be set");
418
419        // Untar and check the single entry at root.
420        let output = std::process::Command::new("tar")
421            .arg("-tzf")
422            .arg(&artifact.path)
423            .output()
424            .expect("tar must be available");
425        let listing = String::from_utf8_lossy(&output.stdout);
426        let entries: Vec<&str> = listing
427            .lines()
428            // tar on macOS may produce the directory entry first; strip it
429            .filter(|l| !l.ends_with('/'))
430            .collect();
431
432        assert_eq!(
433            entries.len(),
434            1,
435            "expected exactly one file in archive, got: {entries:?}"
436        );
437        assert!(
438            entries[0].ends_with("html_to_markdown.so"),
439            "expected html_to_markdown.so at archive root, got: {}",
440            entries[0]
441        );
442
443        // Sidecar must exist.
444        let sidecar = output_dir.join(format!("{}.sha256", artifact.name));
445        assert!(sidecar.exists(), "SHA-256 sidecar must be written");
446    }
447}