1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub enum TsMode {
28 Nts,
30 Ts,
32}
33
34impl TsMode {
35 pub fn as_short(&self) -> &'static str {
39 match self {
40 Self::Nts => "nts",
41 Self::Ts => "ts",
42 }
43 }
44
45 fn as_unix_suffix(&self) -> &'static str {
47 match self {
48 Self::Nts => "nts",
49 Self::Ts => "zts",
50 }
51 }
52
53 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
63pub struct PiePackageOptions<'a> {
65 pub php_version: &'a str,
67 pub ts_mode: TsMode,
69 pub libc_override: Option<&'a str>,
71 pub windows_compiler: Option<&'a str>,
73}
74
75pub 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 let ext_name = config.php_extension_name();
93
94 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 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 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 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 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
141fn 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
182fn 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
204fn 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
232fn 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 #[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 #[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 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 assert!(name.contains("-musl-"), "expected musl in name, got: {name}");
394 }
395
396 #[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 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 assert!(artifact.path.exists(), "archive file must exist");
417 assert!(artifact.checksum.is_some(), "checksum must be set");
418
419 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 .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 let sidecar = output_dir.join(format!("{}.sha256", artifact.name));
445 assert!(sidecar.exists(), "SHA-256 sidecar must be written");
446 }
447}