Skip to main content

studio_worker/engine/
sd_provision.rs

1//! Auto-provision the stable-diffusion.cpp `sd-cli` binary.
2//!
3//! The [`sdcpp`](crate::engine::sdcpp) engine subprocess-invokes
4//! `sd-cli` per image job.  Model weights already download on demand
5//! (see [`download`](crate::engine::download)); this module fills the
6//! remaining gap so a fresh worker is turnkey: on the first image job,
7//! if no `sd-cli` is resolvable, we download the platform's prebuilt
8//! stable-diffusion.cpp **Vulkan** build (universal across NVIDIA /
9//! AMD / Intel, ~37 MB) and extract it into `<models_root>/bin/`, the
10//! PATH-free slot the resolver already prefers.
11//!
12//! The upstream release is pinned for reproducibility.  Overrides:
13//!
14//! * `STUDIO_WORKER_SDCPP_RELEASE` — a `master-<n>-<sha>` tag to fetch
15//!   instead of the pinned default.
16//! * `STUDIO_WORKER_SDCPP_URL` — a full zip URL (skips tag/asset
17//!   resolution entirely; used by tests and air-gapped mirrors).
18//!
19//! Windows resolves the sibling `stable-diffusion.dll` automatically
20//! (same dir as the `.exe`).  Linux / macOS need the loader pointed at
21//! the binary's dir — see [`library_path_env`], applied per job.
22
23use crate::engine::download;
24use anyhow::{anyhow, bail, Context, Result};
25use std::path::{Path, PathBuf};
26use tracing::{info, warn};
27
28/// Tracing target for provisioning.  Stable so operators can filter
29/// with `RUST_LOG=studio_worker::engine::sd_provision=info`.
30const TRACE_TARGET: &str = "studio_worker::engine::sd_provision";
31
32/// Pinned, known-good upstream release.  Bump deliberately after
33/// verifying a newer build still serves our model set.  Overridable
34/// per box via `STUDIO_WORKER_SDCPP_RELEASE`.
35const DEFAULT_RELEASE_TAG: &str = "master-669-2d40a8b";
36
37/// Env override for the release tag.
38const RELEASE_ENV: &str = "STUDIO_WORKER_SDCPP_RELEASE";
39/// Env override for the full zip URL (tests / air-gapped mirrors).
40const URL_ENV: &str = "STUDIO_WORKER_SDCPP_URL";
41
42/// Platform binary name for stable-diffusion.cpp's CLI.
43pub fn binary_name() -> &'static str {
44    if cfg!(target_os = "windows") {
45        "sd-cli.exe"
46    } else {
47        "sd-cli"
48    }
49}
50
51/// Platform shared-library name shipped alongside the binaries.
52fn library_name() -> &'static str {
53    if cfg!(target_os = "windows") {
54        "stable-diffusion.dll"
55    } else if cfg!(target_os = "macos") {
56        "libstable-diffusion.dylib"
57    } else {
58        "libstable-diffusion.so"
59    }
60}
61
62/// The Vulkan loader the prebuilt sd-cli links against, per OS.
63/// `None` on macOS, where the build targets Metal and no Vulkan loader
64/// is involved.
65fn vulkan_loader_name() -> Option<&'static str> {
66    if cfg!(target_os = "windows") {
67        Some("vulkan-1.dll")
68    } else if cfg!(target_os = "macos") {
69        None
70    } else {
71        Some("libvulkan.so.1")
72    }
73}
74
75/// Per-OS remedy for a missing Vulkan loader.  We can't auto-provision
76/// it: it ships with the GPU driver (Windows) or a system package +
77/// driver (Linux), neither of which we can install unattended.
78fn vulkan_remedy() -> &'static str {
79    if cfg!(target_os = "windows") {
80        "install/update your GPU driver (NVIDIA, AMD, or Intel) — it ships \
81         the Vulkan runtime (vulkan-1.dll)"
82    } else {
83        "install the Vulkan loader + a GPU driver, e.g. on Debian/Ubuntu \
84         `sudo apt install libvulkan1 mesa-vulkan-drivers` (plus the \
85         vendor driver for NVIDIA/AMD); verify with `vulkaninfo --summary`"
86    }
87}
88
89/// Whether the Vulkan loader can actually be loaded by the dynamic
90/// linker.  Uses the same `dlopen`/`LoadLibrary` mechanism sd-cli
91/// relies on, so a true result means sd-cli will find the loader too.
92/// Always `true` on macOS (Metal, no Vulkan).  Excluded from coverage:
93/// the outcome is host-GPU-dependent and unstable across CI runners.
94#[cfg_attr(coverage_nightly, coverage(off))]
95fn vulkan_loader_loads() -> bool {
96    match vulkan_loader_name() {
97        None => true,
98        Some(name) => unsafe { libloading::Library::new(name).is_ok() },
99    }
100}
101
102/// Preflight the GPU runtime sd-cli needs.  Returns a clear, actionable
103/// error when the Vulkan loader is absent so the operator sees exactly
104/// what to install instead of a cryptic sd-cli linker/instance crash.
105/// `probe` is injected so the decision + message are unit-testable
106/// without depending on the host's GPU stack.
107fn vulkan_runtime_status_with(loader_loads: bool) -> Result<()> {
108    let Some(loader) = vulkan_loader_name() else {
109        return Ok(()); // macOS / Metal: nothing to check.
110    };
111    if loader_loads {
112        return Ok(());
113    }
114    bail!(
115        "Vulkan runtime not available: the loader `{loader}` could not be \
116         loaded, so stable-diffusion.cpp cannot run on the GPU. We cannot \
117         auto-provision it — {}.",
118        vulkan_remedy()
119    )
120}
121
122/// Live preflight: probes the real loader.  Excluded from coverage for
123/// the same host-dependent reason as [`vulkan_loader_loads`]; the
124/// decision logic is covered via [`vulkan_runtime_status_with`].
125#[cfg_attr(coverage_nightly, coverage(off))]
126pub fn vulkan_runtime_status() -> Result<()> {
127    vulkan_runtime_status_with(vulkan_loader_loads())
128}
129
130/// The release tag to provision — env override or the pinned default.
131fn release_tag() -> String {
132    std::env::var(RELEASE_ENV).unwrap_or_else(|_| DEFAULT_RELEASE_TAG.to_string())
133}
134
135/// The short commit sha embedded in asset filenames is the trailing
136/// `-`-segment of the release tag (`master-669-2d40a8b` -> `2d40a8b`).
137fn sha_from_tag(tag: &str) -> Result<&str> {
138    match tag.rsplit_once('-') {
139        Some((_, sha)) if !sha.is_empty() => Ok(sha),
140        _ => Err(anyhow!("release tag {tag:?} has no '-<sha>' segment")),
141    }
142}
143
144/// Where a platform's prebuilt zip is hosted.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum AssetSource {
147    /// leejet/stable-diffusion.cpp's own releases.
148    Upstream,
149    /// Our own releases — platforms upstream doesn't prebuild
150    /// (currently Linux aarch64), built by `sdcpp-prebuilt.yml` at the
151    /// same sd.cpp commit.
152    SelfHosted,
153}
154
155/// Pick the prebuilt for a target: its host and the asset suffix (the
156/// part between `bin-` and `.zip`).  Vulkan is the universal GPU
157/// backend (one build serves NVIDIA / AMD / Intel); macOS ships a
158/// universal2 Metal binary, so Intel + Apple-Silicon share one asset.
159fn asset_plan(os: &str, arch: &str) -> Result<(AssetSource, &'static str)> {
160    use AssetSource::*;
161    match (os, arch) {
162        ("windows", "x86_64") => Ok((Upstream, "win-vulkan-x64")),
163        ("linux", "x86_64") => Ok((Upstream, "Linux-Ubuntu-24.04-x86_64-vulkan")),
164        // The upstream Darwin build is a universal2 binary (x86_64 +
165        // arm64), so Intel Macs use the very same asset.
166        ("macos", "aarch64") | ("macos", "x86_64") => Ok((Upstream, "Darwin-macOS-15.7.7-arm64")),
167        // Upstream has no aarch64 Linux build; we publish our own.
168        ("linux", "aarch64") => Ok((SelfHosted, "Linux-aarch64-vulkan")),
169        _ => bail!(
170            "no prebuilt stable-diffusion.cpp binary for {os}/{arch}; \
171             install sd-cli manually — see docs/operations/sd-cli-install.md"
172        ),
173    }
174}
175
176/// Build the asset filename for `sha` + `suffix` (upstream's naming
177/// convention, which our self-hosted builds mirror).
178fn asset_name(sha: &str, suffix: &str) -> String {
179    format!("sd-master-{sha}-bin-{suffix}.zip")
180}
181
182/// Our release tag holding the self-hosted prebuilts for `upstream_tag`.
183fn self_hosted_tag(upstream_tag: &str) -> String {
184    format!("sdcpp-prebuilt-{upstream_tag}")
185}
186
187/// The full release-download URL for `tag` on `os`/`arch`, routed to
188/// upstream or our own releases depending on the platform.
189fn download_url(tag: &str, os: &str, arch: &str) -> Result<String> {
190    let sha = sha_from_tag(tag)?;
191    let (source, suffix) = asset_plan(os, arch)?;
192    let asset = asset_name(sha, suffix);
193    Ok(match source {
194        AssetSource::Upstream => format!(
195            "https://github.com/leejet/stable-diffusion.cpp/releases/download/{tag}/{asset}"
196        ),
197        AssetSource::SelfHosted => format!(
198            "https://github.com/webbertakken/studio-worker/releases/download/{}/{asset}",
199            self_hosted_tag(tag)
200        ),
201    })
202}
203
204/// Resolve the zip URL to fetch: the `STUDIO_WORKER_SDCPP_URL`
205/// override if set, otherwise the pinned/overridden release for this
206/// host's platform.
207fn resolve_url() -> Result<String> {
208    if let Ok(url) = std::env::var(URL_ENV) {
209        if !url.is_empty() {
210            return Ok(url);
211        }
212    }
213    download_url(&release_tag(), std::env::consts::OS, std::env::consts::ARCH)
214}
215
216/// If a stable-diffusion shared library sits next to `sd_cli`, return
217/// the `(env-var, dir)` the per-job `Command` must set so the dynamic
218/// linker finds it.  Returns `None` on Windows (sibling DLLs resolve
219/// automatically) and when no sibling library is present (e.g. an
220/// operator's wrapper-script install manages its own load path).
221pub fn library_path_env(sd_cli: &Path) -> Option<(&'static str, PathBuf)> {
222    if cfg!(target_os = "windows") {
223        return None;
224    }
225    let dir = sd_cli.parent()?;
226    if dir.join(library_name()).is_file() {
227        let var = if cfg!(target_os = "macos") {
228            "DYLD_LIBRARY_PATH"
229        } else {
230            "LD_LIBRARY_PATH"
231        };
232        Some((var, dir.to_path_buf()))
233    } else {
234        None
235    }
236}
237
238/// Extract every file in the zip at `zip_path` into `dest_dir`,
239/// flattened to bare file names.  Flattening is also the zip-slip
240/// defence: `Path::file_name` drops every directory component, so a
241/// crafted `../../etc/passwd` entry can only ever land as `passwd`
242/// inside `dest_dir`.  Returns the number of files written.
243#[cfg_attr(coverage_nightly, coverage(off))]
244fn extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<usize> {
245    let file =
246        std::fs::File::open(zip_path).with_context(|| format!("opening {}", zip_path.display()))?;
247    let mut archive = zip::ZipArchive::new(file)
248        .with_context(|| format!("reading zip {}", zip_path.display()))?;
249    std::fs::create_dir_all(dest_dir)
250        .with_context(|| format!("creating {}", dest_dir.display()))?;
251    let mut written = 0usize;
252    for i in 0..archive.len() {
253        let mut entry = archive.by_index(i)?;
254        if entry.is_dir() {
255            continue;
256        }
257        let Some(file_name) = Path::new(entry.name()).file_name().map(|n| n.to_owned()) else {
258            warn!(
259                target: TRACE_TARGET,
260                op = "extract",
261                name = entry.name(),
262                "skipping zip entry with no file name"
263            );
264            continue;
265        };
266        let out = dest_dir.join(&file_name);
267        let mode = entry.unix_mode();
268        let mut writer =
269            std::fs::File::create(&out).with_context(|| format!("creating {}", out.display()))?;
270        std::io::copy(&mut entry, &mut writer)
271            .with_context(|| format!("writing {}", out.display()))?;
272        drop(writer);
273        apply_unix_mode(&out, mode)?;
274        written += 1;
275    }
276    Ok(written)
277}
278
279/// Apply the zip entry's unix mode when present.  No-op off unix.
280#[cfg(unix)]
281fn apply_unix_mode(path: &Path, mode: Option<u32>) -> Result<()> {
282    use std::os::unix::fs::PermissionsExt;
283    if let Some(mode) = mode {
284        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
285            .with_context(|| format!("chmod {}", path.display()))?;
286    }
287    Ok(())
288}
289
290#[cfg(not(unix))]
291fn apply_unix_mode(_path: &Path, _mode: Option<u32>) -> Result<()> {
292    Ok(())
293}
294
295/// Ensure `path` is executable (owner +x) on unix.  No-op off unix.
296#[cfg(unix)]
297fn make_executable(path: &Path) -> Result<()> {
298    use std::os::unix::fs::PermissionsExt;
299    let mut perms = std::fs::metadata(path)
300        .with_context(|| format!("stat {}", path.display()))?
301        .permissions();
302    perms.set_mode(perms.mode() | 0o755);
303    std::fs::set_permissions(path, perms).with_context(|| format!("chmod +x {}", path.display()))
304}
305
306#[cfg(not(unix))]
307fn make_executable(_path: &Path) -> Result<()> {
308    Ok(())
309}
310
311/// Publish every file from `staging` into `target` (created if
312/// needed), overwriting existing files.  Prefers an intra-filesystem
313/// rename (instant for the ~100 MB library) and falls back to a copy
314/// across filesystems.
315fn install_dir(staging: &Path, target: &Path) -> Result<usize> {
316    std::fs::create_dir_all(target).with_context(|| format!("creating {}", target.display()))?;
317    let mut moved = 0usize;
318    for entry in
319        std::fs::read_dir(staging).with_context(|| format!("reading {}", staging.display()))?
320    {
321        let entry = entry?;
322        if !entry.file_type()?.is_file() {
323            continue;
324        }
325        let from = entry.path();
326        let to = target.join(entry.file_name());
327        if to.exists() {
328            std::fs::remove_file(&to).with_context(|| format!("replacing {}", to.display()))?;
329        }
330        if std::fs::rename(&from, &to).is_err() {
331            std::fs::copy(&from, &to)
332                .with_context(|| format!("copying {} -> {}", from.display(), to.display()))?;
333        }
334        moved += 1;
335    }
336    Ok(moved)
337}
338
339/// Ensure `sd-cli` is installed under `<models_root>/bin/`, downloading
340/// and extracting the platform's stable-diffusion.cpp build when it's
341/// missing.  Returns the resolved binary path.  Idempotent: a binary
342/// already present short-circuits the download.
343///
344/// Excluded from coverage: drives a real network download + filesystem
345/// extraction.  The pure pieces it composes ([`asset_name`],
346/// [`download_url`], [`install_dir`], [`library_path_env`]) and the
347/// full path against a served fake zip are covered by tests.
348#[cfg_attr(coverage_nightly, coverage(off))]
349pub fn provision(models_root: &Path) -> Result<PathBuf> {
350    let target_dir = models_root.join("bin");
351    let binary = target_dir.join(binary_name());
352    if binary.is_file() {
353        return Ok(binary);
354    }
355
356    let url = resolve_url()?;
357    info!(
358        target: TRACE_TARGET,
359        op = "provision",
360        url = %url,
361        dest = %target_dir.display(),
362        "sd-cli not found; provisioning stable-diffusion.cpp"
363    );
364
365    std::fs::create_dir_all(models_root)
366        .with_context(|| format!("creating {}", models_root.display()))?;
367    let stamp = format!("{}-{}", std::process::id(), now_nanos());
368    let zip_path = models_root.join(format!(".sd-cli-{stamp}.zip"));
369    let staging = models_root.join(format!(".sd-cli-staging-{stamp}"));
370
371    let result = (|| -> Result<PathBuf> {
372        download::download_file(&url, &zip_path)
373            .with_context(|| format!("downloading sd-cli zip from {url}"))?;
374        let count = extract_zip(&zip_path, &staging)?;
375        let staged_binary = staging.join(binary_name());
376        if !staged_binary.is_file() {
377            bail!(
378                "downloaded sd-cli zip from {url} did not contain {} (extracted {count} files)",
379                binary_name()
380            );
381        }
382        install_dir(&staging, &target_dir)?;
383        make_executable(&binary)?;
384        if !binary.is_file() {
385            bail!("sd-cli install left no binary at {}", binary.display());
386        }
387        Ok(binary.clone())
388    })();
389
390    // Best-effort cleanup of the scratch zip + staging dir on every
391    // exit path so a failed provision can't leave half-extracted
392    // multi-hundred-MB files filling the disk.
393    let _ = std::fs::remove_file(&zip_path);
394    let _ = std::fs::remove_dir_all(&staging);
395
396    match &result {
397        Ok(path) => info!(
398            target: TRACE_TARGET,
399            op = "provision",
400            path = %path.display(),
401            "sd-cli provisioned"
402        ),
403        Err(e) => warn!(
404            target: TRACE_TARGET,
405            op = "provision",
406            error = %e,
407            "sd-cli provisioning failed"
408        ),
409    }
410    result
411}
412
413#[cfg_attr(coverage_nightly, coverage(off))]
414fn now_nanos() -> i64 {
415    chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::io::Write;
422    use tempfile::tempdir;
423
424    #[test]
425    fn sha_from_tag_takes_trailing_segment() {
426        assert_eq!(sha_from_tag("master-669-2d40a8b").unwrap(), "2d40a8b");
427        assert_eq!(sha_from_tag("master-1-abc").unwrap(), "abc");
428    }
429
430    #[test]
431    fn sha_from_tag_rejects_a_tag_without_a_sha() {
432        assert!(sha_from_tag("master").is_err());
433        assert!(sha_from_tag("trailing-").is_err());
434    }
435
436    #[test]
437    fn asset_plan_picks_vulkan_or_universal_for_supported_targets() {
438        use AssetSource::*;
439        assert_eq!(
440            asset_plan("windows", "x86_64").unwrap(),
441            (Upstream, "win-vulkan-x64")
442        );
443        assert_eq!(
444            asset_plan("linux", "x86_64").unwrap(),
445            (Upstream, "Linux-Ubuntu-24.04-x86_64-vulkan")
446        );
447        assert_eq!(
448            asset_plan("macos", "aarch64").unwrap(),
449            (Upstream, "Darwin-macOS-15.7.7-arm64")
450        );
451    }
452
453    #[test]
454    fn asset_plan_makes_intel_mac_and_arm_linux_first_class() {
455        use AssetSource::*;
456        // Intel Macs ride the upstream universal2 Darwin binary.
457        assert_eq!(
458            asset_plan("macos", "x86_64").unwrap(),
459            (Upstream, "Darwin-macOS-15.7.7-arm64")
460        );
461        // aarch64 Linux has no upstream build, so we self-host one.
462        assert_eq!(
463            asset_plan("linux", "aarch64").unwrap(),
464            (SelfHosted, "Linux-aarch64-vulkan")
465        );
466    }
467
468    #[test]
469    fn asset_plan_rejects_unsupported_targets_with_guidance() {
470        let err = asset_plan("freebsd", "x86_64").unwrap_err().to_string();
471        assert!(err.contains("no prebuilt"), "got: {err}");
472        assert!(
473            err.contains("sd-cli-install.md"),
474            "points to the doc: {err}"
475        );
476        assert!(asset_plan("windows", "aarch64").is_err());
477    }
478
479    #[test]
480    fn asset_name_embeds_sha_and_platform() {
481        assert_eq!(
482            asset_name("2d40a8b", "win-vulkan-x64"),
483            "sd-master-2d40a8b-bin-win-vulkan-x64.zip"
484        );
485        assert_eq!(
486            asset_name("2d40a8b", "Linux-aarch64-vulkan"),
487            "sd-master-2d40a8b-bin-Linux-aarch64-vulkan.zip"
488        );
489    }
490
491    #[test]
492    fn download_url_targets_upstream_for_covered_platforms() {
493        let url = download_url("master-669-2d40a8b", "windows", "x86_64").unwrap();
494        let expected = concat!(
495            "https://github.com/leejet/stable-diffusion.cpp/releases/download/",
496            "master-669-2d40a8b/sd-master-2d40a8b-bin-win-vulkan-x64.zip"
497        );
498        assert_eq!(url, expected);
499    }
500
501    #[test]
502    fn download_url_targets_our_release_for_arm_linux() {
503        let url = download_url("master-669-2d40a8b", "linux", "aarch64").unwrap();
504        let expected = concat!(
505            "https://github.com/webbertakken/studio-worker/releases/download/",
506            "sdcpp-prebuilt-master-669-2d40a8b/",
507            "sd-master-2d40a8b-bin-Linux-aarch64-vulkan.zip"
508        );
509        assert_eq!(url, expected);
510    }
511
512    #[test]
513    fn download_url_uses_universal_darwin_asset_for_intel_mac() {
514        let arm = download_url("master-669-2d40a8b", "macos", "aarch64").unwrap();
515        let intel = download_url("master-669-2d40a8b", "macos", "x86_64").unwrap();
516        assert_eq!(arm, intel, "Intel Macs use the same universal2 asset");
517        assert!(intel.contains("Darwin-macOS-15.7.7-arm64"), "got: {intel}");
518    }
519
520    #[test]
521    fn install_dir_moves_files_and_overwrites() {
522        let staging = tempdir().unwrap();
523        let target = tempdir().unwrap();
524        std::fs::write(staging.path().join("sd-cli"), b"new-binary").unwrap();
525        std::fs::write(staging.path().join("libstable-diffusion.so"), b"lib").unwrap();
526        // A stale file in target must be overwritten, not duplicated.
527        std::fs::write(target.path().join("sd-cli"), b"old-binary").unwrap();
528
529        let moved = install_dir(staging.path(), target.path()).unwrap();
530        assert_eq!(moved, 2);
531        assert_eq!(
532            std::fs::read(target.path().join("sd-cli")).unwrap(),
533            b"new-binary"
534        );
535        assert_eq!(
536            std::fs::read(target.path().join("libstable-diffusion.so")).unwrap(),
537            b"lib"
538        );
539        // Files were moved, so staging is now empty of them.
540        assert!(!staging.path().join("sd-cli").exists());
541    }
542
543    #[test]
544    fn extract_zip_flattens_and_defuses_zip_slip() {
545        let dir = tempdir().unwrap();
546        let zip_path = dir.path().join("test.zip");
547        // Build a zip with a nested + a path-traversal entry; both must
548        // land flat inside dest, never escaping it.
549        {
550            let file = std::fs::File::create(&zip_path).unwrap();
551            let mut zw = zip::ZipWriter::new(file);
552            let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
553                .compression_method(zip::CompressionMethod::Deflated);
554            zw.start_file("sd-cli", opts).unwrap();
555            zw.write_all(b"binary").unwrap();
556            zw.start_file("nested/libstable-diffusion.so", opts)
557                .unwrap();
558            zw.write_all(b"lib").unwrap();
559            zw.start_file("../../escape.txt", opts).unwrap();
560            zw.write_all(b"evil").unwrap();
561            zw.finish().unwrap();
562        }
563        let dest = dir.path().join("out");
564        let count = extract_zip(&zip_path, &dest).unwrap();
565        assert_eq!(count, 3);
566        assert_eq!(std::fs::read(dest.join("sd-cli")).unwrap(), b"binary");
567        assert_eq!(
568            std::fs::read(dest.join("libstable-diffusion.so")).unwrap(),
569            b"lib"
570        );
571        // The traversal entry was flattened into dest, not written to a
572        // parent directory.
573        assert!(dest.join("escape.txt").is_file());
574        assert!(!dir.path().join("escape.txt").exists());
575    }
576
577    #[cfg(unix)]
578    #[test]
579    fn extract_zip_preserves_exec_bit() {
580        use std::os::unix::fs::PermissionsExt;
581        let dir = tempdir().unwrap();
582        let zip_path = dir.path().join("exec.zip");
583        {
584            let file = std::fs::File::create(&zip_path).unwrap();
585            let mut zw = zip::ZipWriter::new(file);
586            let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
587                .compression_method(zip::CompressionMethod::Deflated)
588                .unix_permissions(0o755);
589            zw.start_file("sd-cli", opts).unwrap();
590            zw.write_all(b"#!/bin/sh\n").unwrap();
591            zw.finish().unwrap();
592        }
593        let dest = dir.path().join("out");
594        extract_zip(&zip_path, &dest).unwrap();
595        let mode = std::fs::metadata(dest.join("sd-cli"))
596            .unwrap()
597            .permissions()
598            .mode();
599        assert!(mode & 0o111 != 0, "exec bit must survive: {mode:o}");
600    }
601
602    #[cfg(unix)]
603    #[test]
604    fn library_path_env_points_loader_at_sibling_lib() {
605        let dir = tempdir().unwrap();
606        let sd_cli = dir.path().join(binary_name());
607        std::fs::write(&sd_cli, b"bin").unwrap();
608        // No sibling library yet -> nothing to set.
609        assert!(library_path_env(&sd_cli).is_none());
610        // Drop the platform library next to it.
611        std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
612        let (var, env_dir) = library_path_env(&sd_cli).expect("sibling lib resolved");
613        assert!(var == "LD_LIBRARY_PATH" || var == "DYLD_LIBRARY_PATH");
614        assert_eq!(env_dir, dir.path());
615    }
616
617    #[test]
618    fn vulkan_status_ok_when_loader_loads() {
619        // macOS short-circuits to Ok regardless; elsewhere a loadable
620        // loader is Ok.
621        assert!(vulkan_runtime_status_with(true).is_ok());
622    }
623
624    #[test]
625    fn vulkan_status_errors_with_actionable_remedy_when_missing() {
626        let result = vulkan_runtime_status_with(false);
627        if cfg!(target_os = "macos") {
628            // Metal build: there is no Vulkan loader to miss.
629            assert!(result.is_ok());
630        } else {
631            let err = result.unwrap_err().to_string();
632            assert!(err.contains("Vulkan runtime"), "got: {err}");
633            assert!(
634                err.contains("auto-provision"),
635                "must say we can't auto-provision it: {err}"
636            );
637            // The remedy names the concrete fix for this OS.
638            if cfg!(target_os = "windows") {
639                assert!(err.contains("vulkan-1.dll"), "got: {err}");
640                assert!(err.contains("GPU driver"), "got: {err}");
641            } else {
642                assert!(err.contains("libvulkan1"), "got: {err}");
643                assert!(err.contains("vulkaninfo"), "got: {err}");
644            }
645        }
646    }
647
648    #[cfg(target_os = "windows")]
649    #[test]
650    fn library_path_env_is_none_on_windows() {
651        let dir = tempdir().unwrap();
652        let sd_cli = dir.path().join(binary_name());
653        std::fs::write(&sd_cli, b"bin").unwrap();
654        std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
655        assert!(library_path_env(&sd_cli).is_none());
656    }
657}