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::{debug, 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`.  When bumping, also update
35/// the pinned URL in `docs/operations/sd-cli-install.md` and the
36/// `sdcpp-prebuilt.yml` workflow default so the manual playbook, the
37/// self-hosted arm64 build, and the auto-provisioner all share one
38/// known-good sd.cpp commit.
39const DEFAULT_RELEASE_TAG: &str = "master-669-2d40a8b";
40
41/// Env override for the release tag.
42const RELEASE_ENV: &str = "STUDIO_WORKER_SDCPP_RELEASE";
43/// Env override for the full zip URL (tests / air-gapped mirrors).
44const URL_ENV: &str = "STUDIO_WORKER_SDCPP_URL";
45
46/// Platform binary name for stable-diffusion.cpp's CLI.
47pub fn binary_name() -> &'static str {
48    if cfg!(target_os = "windows") {
49        "sd-cli.exe"
50    } else {
51        "sd-cli"
52    }
53}
54
55/// Platform shared-library name shipped alongside the binaries.
56fn library_name() -> &'static str {
57    if cfg!(target_os = "windows") {
58        "stable-diffusion.dll"
59    } else if cfg!(target_os = "macos") {
60        "libstable-diffusion.dylib"
61    } else {
62        "libstable-diffusion.so"
63    }
64}
65
66/// The Vulkan loader the prebuilt sd-cli links against, per OS.
67/// `None` on macOS, where the build targets Metal and no Vulkan loader
68/// is involved.
69fn vulkan_loader_name() -> Option<&'static str> {
70    if cfg!(target_os = "windows") {
71        Some("vulkan-1.dll")
72    } else if cfg!(target_os = "macos") {
73        None
74    } else {
75        Some("libvulkan.so.1")
76    }
77}
78
79/// Per-OS remedy for a missing Vulkan loader.  We can't auto-provision
80/// it: it ships with the GPU driver (Windows) or a system package +
81/// driver (Linux), neither of which we can install unattended.
82fn vulkan_remedy() -> &'static str {
83    if cfg!(target_os = "windows") {
84        "install/update your GPU driver (NVIDIA, AMD, or Intel) — it ships \
85         the Vulkan runtime (vulkan-1.dll)"
86    } else {
87        "install the Vulkan loader + a GPU driver, e.g. on Debian/Ubuntu \
88         `sudo apt install libvulkan1 mesa-vulkan-drivers` (plus the \
89         vendor driver for NVIDIA/AMD); verify with `vulkaninfo --summary`"
90    }
91}
92
93/// Whether the Vulkan loader can actually be loaded by the dynamic
94/// linker.  Uses the same `dlopen`/`LoadLibrary` mechanism sd-cli
95/// relies on, so a true result means sd-cli will find the loader too.
96/// Always `true` on macOS (Metal, no Vulkan).  Excluded from coverage:
97/// the outcome is host-GPU-dependent and unstable across CI runners.
98#[cfg_attr(coverage_nightly, coverage(off))]
99fn vulkan_loader_loads() -> bool {
100    match vulkan_loader_name() {
101        None => true,
102        Some(name) => unsafe { libloading::Library::new(name).is_ok() },
103    }
104}
105
106/// Preflight the GPU runtime sd-cli needs.  Returns a clear, actionable
107/// error when the Vulkan loader is absent so the operator sees exactly
108/// what to install instead of a cryptic sd-cli linker/instance crash.
109/// `probe` is injected so the decision + message are unit-testable
110/// without depending on the host's GPU stack.
111fn vulkan_runtime_status_with(loader_loads: bool) -> Result<()> {
112    let Some(loader) = vulkan_loader_name() else {
113        return Ok(()); // macOS / Metal: nothing to check.
114    };
115    if loader_loads {
116        return Ok(());
117    }
118    bail!(
119        "Vulkan runtime not available: the loader `{loader}` could not be \
120         loaded, so stable-diffusion.cpp cannot run on the GPU. We cannot \
121         auto-provision it — {}.",
122        vulkan_remedy()
123    )
124}
125
126/// Live preflight: probes the real loader.  Excluded from coverage for
127/// the same host-dependent reason as [`vulkan_loader_loads`]; the
128/// decision logic is covered via [`vulkan_runtime_status_with`].
129#[cfg_attr(coverage_nightly, coverage(off))]
130pub fn vulkan_runtime_status() -> Result<()> {
131    vulkan_runtime_status_with(vulkan_loader_loads())
132}
133
134/// Choose the release tag from an optional override, logging which
135/// source won so an operator can confirm their
136/// `STUDIO_WORKER_SDCPP_RELEASE` took effect (rather than being
137/// silently ignored, e.g. a typo'd var name).  Pure — the override is
138/// injected — so the decision + breadcrumb are unit-testable without
139/// touching the process-global environment.
140fn select_release_tag(override_tag: Option<String>) -> String {
141    match override_tag {
142        Some(tag) => {
143            info!(
144                target: TRACE_TARGET,
145                op = "resolve-url",
146                tag = %tag,
147                source = RELEASE_ENV,
148                "using sd-cli release-tag override"
149            );
150            tag
151        }
152        None => {
153            debug!(
154                target: TRACE_TARGET,
155                op = "resolve-url",
156                tag = DEFAULT_RELEASE_TAG,
157                "using pinned sd-cli release tag"
158            );
159            DEFAULT_RELEASE_TAG.to_string()
160        }
161    }
162}
163
164/// The release tag to provision — env override or the pinned default.
165/// Thin env-reading wrapper over [`select_release_tag`]; excluded from
166/// coverage because it reads the process environment (the decision +
167/// logging are covered via [`select_release_tag`]).
168#[cfg_attr(coverage_nightly, coverage(off))]
169fn release_tag() -> String {
170    select_release_tag(std::env::var(RELEASE_ENV).ok())
171}
172
173/// The short commit sha embedded in asset filenames is the trailing
174/// `-`-segment of the release tag (`master-669-2d40a8b` -> `2d40a8b`).
175fn sha_from_tag(tag: &str) -> Result<&str> {
176    match tag.rsplit_once('-') {
177        Some((_, sha)) if !sha.is_empty() => Ok(sha),
178        _ => Err(anyhow!("release tag {tag:?} has no '-<sha>' segment")),
179    }
180}
181
182/// Where a platform's prebuilt zip is hosted.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184enum AssetSource {
185    /// leejet/stable-diffusion.cpp's own releases.
186    Upstream,
187    /// Our own releases — platforms upstream doesn't prebuild
188    /// (currently Linux aarch64), built by `sdcpp-prebuilt.yml` at the
189    /// same sd.cpp commit.
190    SelfHosted,
191}
192
193/// Pick the prebuilt for a target: its host and the asset suffix (the
194/// part between `bin-` and `.zip`).  Vulkan is the universal GPU
195/// backend (one build serves NVIDIA / AMD / Intel); macOS ships a
196/// universal2 Metal binary, so Intel + Apple-Silicon share one asset.
197fn asset_plan(os: &str, arch: &str) -> Result<(AssetSource, &'static str)> {
198    use AssetSource::*;
199    match (os, arch) {
200        ("windows", "x86_64") => Ok((Upstream, "win-vulkan-x64")),
201        ("linux", "x86_64") => Ok((Upstream, "Linux-Ubuntu-24.04-x86_64-vulkan")),
202        // The upstream Darwin build is a universal2 binary (x86_64 +
203        // arm64), so Intel Macs use the very same asset.
204        ("macos", "aarch64") | ("macos", "x86_64") => Ok((Upstream, "Darwin-macOS-15.7.7-arm64")),
205        // Upstream has no aarch64 Linux build; we publish our own.
206        ("linux", "aarch64") => Ok((SelfHosted, "Linux-aarch64-vulkan")),
207        _ => bail!(
208            "no prebuilt stable-diffusion.cpp binary for {os}/{arch}; \
209             install sd-cli manually — see docs/operations/sd-cli-install.md"
210        ),
211    }
212}
213
214/// Build the asset filename for `sha` + `suffix` (upstream's naming
215/// convention, which our self-hosted builds mirror).
216fn asset_name(sha: &str, suffix: &str) -> String {
217    format!("sd-master-{sha}-bin-{suffix}.zip")
218}
219
220/// Our release tag holding the self-hosted prebuilts for `upstream_tag`.
221fn self_hosted_tag(upstream_tag: &str) -> String {
222    format!("sdcpp-prebuilt-{upstream_tag}")
223}
224
225/// The full release-download URL for `tag` on `os`/`arch`, routed to
226/// upstream or our own releases depending on the platform.
227fn download_url(tag: &str, os: &str, arch: &str) -> Result<String> {
228    let sha = sha_from_tag(tag)?;
229    let (source, suffix) = asset_plan(os, arch)?;
230    let asset = asset_name(sha, suffix);
231    Ok(match source {
232        AssetSource::Upstream => format!(
233            "https://github.com/leejet/stable-diffusion.cpp/releases/download/{tag}/{asset}"
234        ),
235        AssetSource::SelfHosted => format!(
236            "https://github.com/webbertakken/studio-worker/releases/download/{}/{asset}",
237            self_hosted_tag(tag)
238        ),
239    })
240}
241
242/// Choose the zip URL: a non-empty `STUDIO_WORKER_SDCPP_URL` override
243/// wins (and is logged so the operator can confirm it took effect),
244/// otherwise fall back to `default_url`.  Pure — both inputs are
245/// injected — so the precedence + breadcrumb are unit-testable without
246/// touching the environment or the network.
247fn select_url(
248    override_url: Option<String>,
249    default_url: impl FnOnce() -> Result<String>,
250) -> Result<String> {
251    if let Some(url) = override_url {
252        if !url.is_empty() {
253            info!(
254                target: TRACE_TARGET,
255                op = "resolve-url",
256                url = %url,
257                source = URL_ENV,
258                "using sd-cli zip-URL override"
259            );
260            return Ok(url);
261        }
262        // Present but empty (`STUDIO_WORKER_SDCPP_URL=`): the override is
263        // dropped and the pinned default is used.  Surface it instead of
264        // silently swallowing it, so a blank env value (a unit-file or CI
265        // misconfiguration) doesn't leave the operator wondering why their
266        // mirror override never took effect.
267        warn!(
268            target: TRACE_TARGET,
269            op = "resolve-url",
270            source = URL_ENV,
271            "ignoring empty STUDIO_WORKER_SDCPP_URL override; using the default release URL"
272        );
273    }
274    default_url()
275}
276
277/// Resolve the zip URL to fetch: the `STUDIO_WORKER_SDCPP_URL`
278/// override if set, otherwise the pinned/overridden release for this
279/// host's platform.  Thin env-reading wrapper over [`select_url`];
280/// excluded from coverage because it reads the process environment.
281#[cfg_attr(coverage_nightly, coverage(off))]
282fn resolve_url() -> Result<String> {
283    select_url(std::env::var(URL_ENV).ok(), || {
284        download_url(&release_tag(), std::env::consts::OS, std::env::consts::ARCH)
285    })
286}
287
288/// If a stable-diffusion shared library sits next to `sd_cli`, return
289/// the `(env-var, dir)` the per-job `Command` must set so the dynamic
290/// linker finds it.  Returns `None` on Windows (sibling DLLs resolve
291/// automatically) and when no sibling library is present (e.g. an
292/// operator's wrapper-script install manages its own load path).
293pub fn library_path_env(sd_cli: &Path) -> Option<(&'static str, PathBuf)> {
294    if cfg!(target_os = "windows") {
295        return None;
296    }
297    let dir = sd_cli.parent()?;
298    if dir.join(library_name()).is_file() {
299        let var = if cfg!(target_os = "macos") {
300            "DYLD_LIBRARY_PATH"
301        } else {
302            "LD_LIBRARY_PATH"
303        };
304        Some((var, dir.to_path_buf()))
305    } else {
306        None
307    }
308}
309
310/// Extract every file in the zip at `zip_path` into `dest_dir`,
311/// flattened to bare file names.  Flattening is also the zip-slip
312/// defence: `Path::file_name` drops every directory component, so a
313/// crafted `../../etc/passwd` entry can only ever land as `passwd`
314/// inside `dest_dir`.  Returns the number of files written.
315#[cfg_attr(coverage_nightly, coverage(off))]
316fn extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<usize> {
317    let file =
318        std::fs::File::open(zip_path).with_context(|| format!("opening {}", zip_path.display()))?;
319    let mut archive = zip::ZipArchive::new(file)
320        .with_context(|| format!("reading zip {}", zip_path.display()))?;
321    std::fs::create_dir_all(dest_dir)
322        .with_context(|| format!("creating {}", dest_dir.display()))?;
323    let mut written = 0usize;
324    for i in 0..archive.len() {
325        let mut entry = archive.by_index(i)?;
326        if entry.is_dir() {
327            continue;
328        }
329        let Some(file_name) = Path::new(entry.name()).file_name().map(|n| n.to_owned()) else {
330            warn!(
331                target: TRACE_TARGET,
332                op = "extract",
333                name = entry.name(),
334                "skipping zip entry with no file name"
335            );
336            continue;
337        };
338        let out = dest_dir.join(&file_name);
339        let mode = entry.unix_mode();
340        let mut writer =
341            std::fs::File::create(&out).with_context(|| format!("creating {}", out.display()))?;
342        std::io::copy(&mut entry, &mut writer)
343            .with_context(|| format!("writing {}", out.display()))?;
344        drop(writer);
345        apply_unix_mode(&out, mode)?;
346        written += 1;
347    }
348    Ok(written)
349}
350
351/// Apply the zip entry's unix mode when present.  No-op off unix.
352#[cfg(unix)]
353fn apply_unix_mode(path: &Path, mode: Option<u32>) -> Result<()> {
354    use std::os::unix::fs::PermissionsExt;
355    if let Some(mode) = mode {
356        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
357            .with_context(|| format!("chmod {}", path.display()))?;
358    }
359    Ok(())
360}
361
362#[cfg(not(unix))]
363fn apply_unix_mode(_path: &Path, _mode: Option<u32>) -> Result<()> {
364    Ok(())
365}
366
367/// Ensure `path` is executable (owner +x) on unix.  No-op off unix.
368#[cfg(unix)]
369fn make_executable(path: &Path) -> Result<()> {
370    use std::os::unix::fs::PermissionsExt;
371    let mut perms = std::fs::metadata(path)
372        .with_context(|| format!("stat {}", path.display()))?
373        .permissions();
374    perms.set_mode(perms.mode() | 0o755);
375    std::fs::set_permissions(path, perms).with_context(|| format!("chmod +x {}", path.display()))
376}
377
378#[cfg(not(unix))]
379fn make_executable(_path: &Path) -> Result<()> {
380    Ok(())
381}
382
383/// Publish every file from `staging` into `target` (created if
384/// needed), overwriting existing files.  Prefers an intra-filesystem
385/// rename (instant for the ~100 MB library) and falls back to a copy
386/// across filesystems.
387fn install_dir(staging: &Path, target: &Path) -> Result<usize> {
388    std::fs::create_dir_all(target).with_context(|| format!("creating {}", target.display()))?;
389    let mut moved = 0usize;
390    for entry in
391        std::fs::read_dir(staging).with_context(|| format!("reading {}", staging.display()))?
392    {
393        let entry = entry?;
394        if !entry.file_type()?.is_file() {
395            continue;
396        }
397        let from = entry.path();
398        let to = target.join(entry.file_name());
399        if to.exists() {
400            std::fs::remove_file(&to).with_context(|| format!("replacing {}", to.display()))?;
401        }
402        if std::fs::rename(&from, &to).is_err() {
403            std::fs::copy(&from, &to)
404                .with_context(|| format!("copying {} -> {}", from.display(), to.display()))?;
405        }
406        moved += 1;
407    }
408    Ok(moved)
409}
410
411/// Best-effort removal of the provisioning scratch zip + staging dir.
412/// Unlike a bare `let _ = remove(..)`, a failed removal is logged: a
413/// leftover multi-hundred-MB scratch file silently filling the disk is
414/// the exact failure this cleanup guards against, so operators must see
415/// it.  A NotFound (the path was already gone) is the normal case and
416/// stays quiet.
417fn clean_scratch(zip_path: &Path, staging: &Path) {
418    if let Err(e) = std::fs::remove_file(zip_path) {
419        if e.kind() != std::io::ErrorKind::NotFound {
420            warn!(
421                target: TRACE_TARGET,
422                op = "cleanup",
423                path = %zip_path.display(),
424                error = %e,
425                "could not remove sd-cli scratch zip; it may fill the disk"
426            );
427        }
428    }
429    if let Err(e) = std::fs::remove_dir_all(staging) {
430        if e.kind() != std::io::ErrorKind::NotFound {
431            warn!(
432                target: TRACE_TARGET,
433                op = "cleanup",
434                path = %staging.display(),
435                error = %e,
436                "could not remove sd-cli staging dir; it may fill the disk"
437            );
438        }
439    }
440}
441
442/// Ensure `sd-cli` is installed under `<models_root>/bin/`, downloading
443/// and extracting the platform's stable-diffusion.cpp build when it's
444/// missing.  Returns the resolved binary path.  Idempotent: a binary
445/// already present short-circuits the download.
446///
447/// Excluded from coverage: drives a real network download + filesystem
448/// extraction.  The pure pieces it composes ([`asset_name`],
449/// [`download_url`], [`install_dir`], [`library_path_env`]) and the
450/// full path against a served fake zip are covered by tests.
451#[cfg_attr(coverage_nightly, coverage(off))]
452pub fn provision(models_root: &Path) -> Result<PathBuf> {
453    let target_dir = models_root.join("bin");
454    let binary = target_dir.join(binary_name());
455    if binary.is_file() {
456        return Ok(binary);
457    }
458
459    let url = resolve_url()?;
460    info!(
461        target: TRACE_TARGET,
462        op = "provision",
463        url = %url,
464        dest = %target_dir.display(),
465        "sd-cli not found; provisioning stable-diffusion.cpp"
466    );
467
468    std::fs::create_dir_all(models_root)
469        .with_context(|| format!("creating {}", models_root.display()))?;
470    let stamp = format!("{}-{}", std::process::id(), now_nanos());
471    let zip_path = models_root.join(format!(".sd-cli-{stamp}.zip"));
472    let staging = models_root.join(format!(".sd-cli-staging-{stamp}"));
473
474    let result = (|| -> Result<PathBuf> {
475        download::download_file(&url, &zip_path)
476            .with_context(|| format!("downloading sd-cli zip from {url}"))?;
477        let count = extract_zip(&zip_path, &staging)?;
478        let staged_binary = staging.join(binary_name());
479        if !staged_binary.is_file() {
480            bail!(
481                "downloaded sd-cli zip from {url} did not contain {} (extracted {count} files)",
482                binary_name()
483            );
484        }
485        install_dir(&staging, &target_dir)?;
486        make_executable(&binary)?;
487        if !binary.is_file() {
488            bail!("sd-cli install left no binary at {}", binary.display());
489        }
490        Ok(binary.clone())
491    })();
492
493    // Best-effort cleanup of the scratch zip + staging dir on every
494    // exit path so a failed provision can't leave half-extracted
495    // multi-hundred-MB files filling the disk.  Removal failures are
496    // logged, not swallowed.
497    clean_scratch(&zip_path, &staging);
498
499    match &result {
500        Ok(path) => info!(
501            target: TRACE_TARGET,
502            op = "provision",
503            path = %path.display(),
504            "sd-cli provisioned"
505        ),
506        Err(e) => warn!(
507            target: TRACE_TARGET,
508            op = "provision",
509            error = %e,
510            "sd-cli provisioning failed"
511        ),
512    }
513    result
514}
515
516#[cfg_attr(coverage_nightly, coverage(off))]
517fn now_nanos() -> i64 {
518    chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use std::io::Write;
525    use tempfile::tempdir;
526
527    #[test]
528    fn sha_from_tag_takes_trailing_segment() {
529        assert_eq!(sha_from_tag("master-669-2d40a8b").unwrap(), "2d40a8b");
530        assert_eq!(sha_from_tag("master-1-abc").unwrap(), "abc");
531    }
532
533    #[test]
534    fn sha_from_tag_rejects_a_tag_without_a_sha() {
535        assert!(sha_from_tag("master").is_err());
536        assert!(sha_from_tag("trailing-").is_err());
537    }
538
539    #[test]
540    fn asset_plan_picks_vulkan_or_universal_for_supported_targets() {
541        use AssetSource::*;
542        assert_eq!(
543            asset_plan("windows", "x86_64").unwrap(),
544            (Upstream, "win-vulkan-x64")
545        );
546        assert_eq!(
547            asset_plan("linux", "x86_64").unwrap(),
548            (Upstream, "Linux-Ubuntu-24.04-x86_64-vulkan")
549        );
550        assert_eq!(
551            asset_plan("macos", "aarch64").unwrap(),
552            (Upstream, "Darwin-macOS-15.7.7-arm64")
553        );
554    }
555
556    #[test]
557    fn asset_plan_makes_intel_mac_and_arm_linux_first_class() {
558        use AssetSource::*;
559        // Intel Macs ride the upstream universal2 Darwin binary.
560        assert_eq!(
561            asset_plan("macos", "x86_64").unwrap(),
562            (Upstream, "Darwin-macOS-15.7.7-arm64")
563        );
564        // aarch64 Linux has no upstream build, so we self-host one.
565        assert_eq!(
566            asset_plan("linux", "aarch64").unwrap(),
567            (SelfHosted, "Linux-aarch64-vulkan")
568        );
569    }
570
571    #[test]
572    fn asset_plan_rejects_unsupported_targets_with_guidance() {
573        let err = asset_plan("freebsd", "x86_64").unwrap_err().to_string();
574        assert!(err.contains("no prebuilt"), "got: {err}");
575        assert!(
576            err.contains("sd-cli-install.md"),
577            "points to the doc: {err}"
578        );
579        assert!(asset_plan("windows", "aarch64").is_err());
580    }
581
582    #[test]
583    fn asset_name_embeds_sha_and_platform() {
584        assert_eq!(
585            asset_name("2d40a8b", "win-vulkan-x64"),
586            "sd-master-2d40a8b-bin-win-vulkan-x64.zip"
587        );
588        assert_eq!(
589            asset_name("2d40a8b", "Linux-aarch64-vulkan"),
590            "sd-master-2d40a8b-bin-Linux-aarch64-vulkan.zip"
591        );
592    }
593
594    #[test]
595    fn download_url_targets_upstream_for_covered_platforms() {
596        let url = download_url("master-669-2d40a8b", "windows", "x86_64").unwrap();
597        let expected = concat!(
598            "https://github.com/leejet/stable-diffusion.cpp/releases/download/",
599            "master-669-2d40a8b/sd-master-2d40a8b-bin-win-vulkan-x64.zip"
600        );
601        assert_eq!(url, expected);
602    }
603
604    #[test]
605    fn download_url_targets_our_release_for_arm_linux() {
606        let url = download_url("master-669-2d40a8b", "linux", "aarch64").unwrap();
607        let expected = concat!(
608            "https://github.com/webbertakken/studio-worker/releases/download/",
609            "sdcpp-prebuilt-master-669-2d40a8b/",
610            "sd-master-2d40a8b-bin-Linux-aarch64-vulkan.zip"
611        );
612        assert_eq!(url, expected);
613    }
614
615    #[test]
616    fn download_url_uses_universal_darwin_asset_for_intel_mac() {
617        let arm = download_url("master-669-2d40a8b", "macos", "aarch64").unwrap();
618        let intel = download_url("master-669-2d40a8b", "macos", "x86_64").unwrap();
619        assert_eq!(arm, intel, "Intel Macs use the same universal2 asset");
620        assert!(intel.contains("Darwin-macOS-15.7.7-arm64"), "got: {intel}");
621    }
622
623    #[test]
624    fn select_release_tag_prefers_the_override() {
625        assert_eq!(
626            select_release_tag(Some("master-700-deadbee".into())),
627            "master-700-deadbee"
628        );
629    }
630
631    #[test]
632    fn select_release_tag_falls_back_to_the_pinned_default() {
633        assert_eq!(select_release_tag(None), DEFAULT_RELEASE_TAG);
634    }
635
636    #[test]
637    fn select_release_tag_logs_the_override_source() {
638        let logs = crate::test_support::capture(|| {
639            let _ = select_release_tag(Some("master-700-deadbee".into()));
640        });
641        assert!(
642            logs.contains("STUDIO_WORKER_SDCPP_RELEASE"),
643            "override log must name the env var: {logs}"
644        );
645        assert!(logs.contains("master-700-deadbee"), "got: {logs}");
646        assert!(logs.contains("override"), "got: {logs}");
647    }
648
649    #[test]
650    fn select_url_prefers_a_non_empty_override() {
651        let url = select_url(Some("https://mirror.example/sd.zip".into()), || {
652            panic!("default must not be consulted when an override is present")
653        })
654        .unwrap();
655        assert_eq!(url, "https://mirror.example/sd.zip");
656    }
657
658    #[test]
659    fn select_url_ignores_an_empty_override_and_falls_back() {
660        let url = select_url(Some(String::new()), || Ok("fallback".into())).unwrap();
661        assert_eq!(url, "fallback");
662    }
663
664    #[test]
665    fn select_url_falls_back_when_no_override_is_set() {
666        let url = select_url(None, || Ok("fallback".into())).unwrap();
667        assert_eq!(url, "fallback");
668    }
669
670    #[test]
671    fn select_url_propagates_a_default_resolution_error() {
672        let err = select_url(None, || bail!("no prebuilt for this platform"))
673            .unwrap_err()
674            .to_string();
675        assert!(err.contains("no prebuilt"), "got: {err}");
676    }
677
678    #[test]
679    fn select_url_logs_the_override_source() {
680        let logs = crate::test_support::capture(|| {
681            let _ = select_url(Some("https://mirror.example/sd.zip".into()), || {
682                Ok("unused".into())
683            });
684        });
685        assert!(
686            logs.contains("STUDIO_WORKER_SDCPP_URL"),
687            "override log must name the env var: {logs}"
688        );
689        assert!(
690            logs.contains("https://mirror.example/sd.zip"),
691            "got: {logs}"
692        );
693    }
694
695    #[test]
696    fn select_url_warns_when_the_override_is_present_but_empty() {
697        // An override that's present but empty (`STUDIO_WORKER_SDCPP_URL=`,
698        // e.g. an `Environment="STUDIO_WORKER_SDCPP_URL="` line in a unit file
699        // or a CI that sets the var conditionally and leaves it blank) is a
700        // misconfiguration: the override is dropped and the pinned default is
701        // used.  Without a breadcrumb the operator has no trace of why their
702        // mirror override never took effect — the symmetric silent gap to the
703        // non-empty "took effect" log above.
704        let logs = crate::test_support::capture(|| {
705            let url = select_url(Some(String::new()), || Ok("fallback".into())).unwrap();
706            assert_eq!(url, "fallback", "an empty override must still fall back");
707        });
708        assert!(
709            logs.contains("WARN"),
710            "expected a WARN breadcrumb, got: {logs}"
711        );
712        assert!(
713            logs.contains("STUDIO_WORKER_SDCPP_URL"),
714            "the warning must name the ignored env var: {logs}"
715        );
716        assert!(
717            logs.contains("op=\"resolve-url\""),
718            "expected the resolve-url op field: {logs}"
719        );
720    }
721
722    #[test]
723    fn install_dir_moves_files_and_overwrites() {
724        let staging = tempdir().unwrap();
725        let target = tempdir().unwrap();
726        std::fs::write(staging.path().join("sd-cli"), b"new-binary").unwrap();
727        std::fs::write(staging.path().join("libstable-diffusion.so"), b"lib").unwrap();
728        // A stale file in target must be overwritten, not duplicated.
729        std::fs::write(target.path().join("sd-cli"), b"old-binary").unwrap();
730
731        let moved = install_dir(staging.path(), target.path()).unwrap();
732        assert_eq!(moved, 2);
733        assert_eq!(
734            std::fs::read(target.path().join("sd-cli")).unwrap(),
735            b"new-binary"
736        );
737        assert_eq!(
738            std::fs::read(target.path().join("libstable-diffusion.so")).unwrap(),
739            b"lib"
740        );
741        // Files were moved, so staging is now empty of them.
742        assert!(!staging.path().join("sd-cli").exists());
743    }
744
745    #[test]
746    fn install_dir_skips_subdirectories_and_counts_only_files() {
747        // `install_dir` publishes a *flat* set of files; a directory in
748        // staging (a malformed build archive, or a future extract that
749        // stops flattening) must be skipped, not recursed into or
750        // copied as-is, and must not inflate the moved-file count the
751        // provisioner relies on.
752        let staging = tempdir().unwrap();
753        let target = tempdir().unwrap();
754        std::fs::write(staging.path().join("sd-cli"), b"binary").unwrap();
755        std::fs::write(staging.path().join("libstable-diffusion.so"), b"lib").unwrap();
756        let nested = staging.path().join("nested");
757        std::fs::create_dir(&nested).unwrap();
758        std::fs::write(nested.join("buried"), b"should-not-publish").unwrap();
759
760        let moved = install_dir(staging.path(), target.path()).unwrap();
761
762        // Only the two top-level files count; the directory is skipped.
763        assert_eq!(moved, 2);
764        assert!(target.path().join("sd-cli").is_file());
765        assert!(target.path().join("libstable-diffusion.so").is_file());
766        // The directory (and its contents) must never reach the target.
767        assert!(
768            !target.path().join("nested").exists(),
769            "a staging subdirectory must not be published"
770        );
771        assert!(
772            !target.path().join("buried").exists(),
773            "a staging subdirectory's contents must not be flattened into the target"
774        );
775    }
776
777    #[test]
778    fn clean_scratch_removes_zip_and_staging_quietly() {
779        let dir = tempdir().unwrap();
780        let zip = dir.path().join("scratch.zip");
781        let staging = dir.path().join("staging");
782        std::fs::write(&zip, b"zip").unwrap();
783        std::fs::create_dir_all(&staging).unwrap();
784        std::fs::write(staging.join("sd-cli"), b"bin").unwrap();
785
786        let (zip_c, staging_c) = (zip.clone(), staging.clone());
787        let logs = crate::test_support::capture(move || clean_scratch(&zip_c, &staging_c));
788
789        assert!(!zip.exists(), "scratch zip must be removed");
790        assert!(!staging.exists(), "staging dir must be removed");
791        assert!(
792            !logs.contains("could not remove"),
793            "a clean removal must not warn: {logs}"
794        );
795    }
796
797    #[test]
798    fn clean_scratch_is_silent_when_paths_are_already_gone() {
799        let dir = tempdir().unwrap();
800        let zip = dir.path().join("missing.zip");
801        let staging = dir.path().join("missing-staging");
802
803        let (zip_c, staging_c) = (zip.clone(), staging.clone());
804        let logs = crate::test_support::capture(move || clean_scratch(&zip_c, &staging_c));
805
806        // A NotFound (already gone) is the normal case and must stay quiet.
807        assert!(
808            !logs.contains("could not remove"),
809            "an already-clean slot must not warn: {logs}"
810        );
811    }
812
813    #[test]
814    fn clean_scratch_warns_when_removal_fails() {
815        let dir = tempdir().unwrap();
816        // A directory where the zip is expected makes `remove_file` fail
817        // with a non-NotFound error; a file where the staging dir is
818        // expected makes `remove_dir_all` fail likewise.  A leftover
819        // multi-hundred-MB scratch file silently filling the disk is
820        // exactly what this guards against, so both must surface.
821        let zip = dir.path().join("zip-slot");
822        std::fs::create_dir_all(&zip).unwrap();
823        let staging = dir.path().join("staging-slot");
824        std::fs::write(&staging, b"not a dir").unwrap();
825
826        let (zip_c, staging_c) = (zip.clone(), staging.clone());
827        let logs = crate::test_support::capture(move || clean_scratch(&zip_c, &staging_c));
828
829        assert!(
830            logs.matches("could not remove").count() >= 2,
831            "both failed removals must warn: {logs}"
832        );
833        assert!(
834            logs.contains("fill the disk"),
835            "the warning must flag the disk-fill risk: {logs}"
836        );
837    }
838
839    #[test]
840    fn extract_zip_flattens_and_defuses_zip_slip() {
841        let dir = tempdir().unwrap();
842        let zip_path = dir.path().join("test.zip");
843        // Build a zip with a nested + a path-traversal entry; both must
844        // land flat inside dest, never escaping it.
845        {
846            let file = std::fs::File::create(&zip_path).unwrap();
847            let mut zw = zip::ZipWriter::new(file);
848            let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
849                .compression_method(zip::CompressionMethod::Deflated);
850            zw.start_file("sd-cli", opts).unwrap();
851            zw.write_all(b"binary").unwrap();
852            zw.start_file("nested/libstable-diffusion.so", opts)
853                .unwrap();
854            zw.write_all(b"lib").unwrap();
855            zw.start_file("../../escape.txt", opts).unwrap();
856            zw.write_all(b"evil").unwrap();
857            zw.finish().unwrap();
858        }
859        let dest = dir.path().join("out");
860        let count = extract_zip(&zip_path, &dest).unwrap();
861        assert_eq!(count, 3);
862        assert_eq!(std::fs::read(dest.join("sd-cli")).unwrap(), b"binary");
863        assert_eq!(
864            std::fs::read(dest.join("libstable-diffusion.so")).unwrap(),
865            b"lib"
866        );
867        // The traversal entry was flattened into dest, not written to a
868        // parent directory.
869        assert!(dest.join("escape.txt").is_file());
870        assert!(!dir.path().join("escape.txt").exists());
871    }
872
873    #[test]
874    fn extract_zip_skips_directory_entries() {
875        // Real stable-diffusion.cpp prebuilt zips carry explicit
876        // directory entries (a top-level `build/` marker, a `bin/`
877        // dir, etc.).  Those must be skipped, not turned into spurious
878        // empty files in the flat output dir: a directory entry's name
879        // ends in `/`, so without the `is_dir` guard `file_name()`
880        // would strip the slash and write an empty `build` file
881        // alongside the real binary, and inflate the written-file count
882        // the provisioner reports.
883        let dir = tempdir().unwrap();
884        let zip_path = dir.path().join("with-dirs.zip");
885        {
886            let file = std::fs::File::create(&zip_path).unwrap();
887            let mut zw = zip::ZipWriter::new(file);
888            let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
889                .compression_method(zip::CompressionMethod::Deflated);
890            zw.add_directory("build/", opts).unwrap();
891            zw.start_file("sd-cli", opts).unwrap();
892            zw.write_all(b"binary").unwrap();
893            zw.add_directory("nested/empty/", opts).unwrap();
894            zw.finish().unwrap();
895        }
896        let dest = dir.path().join("out");
897        let count = extract_zip(&zip_path, &dest).unwrap();
898        // Only the single real file counts; both directory entries are skipped.
899        assert_eq!(
900            count, 1,
901            "directory entries must not count as written files"
902        );
903        assert_eq!(std::fs::read(dest.join("sd-cli")).unwrap(), b"binary");
904        // No spurious file is created from a directory entry's slash-stripped name.
905        assert!(
906            !dest.join("build").exists(),
907            "a directory entry must not become a file in the flat output"
908        );
909        assert!(
910            !dest.join("empty").exists(),
911            "a nested directory entry must not become a file either"
912        );
913    }
914
915    #[cfg(unix)]
916    #[test]
917    fn extract_zip_preserves_exec_bit() {
918        use std::os::unix::fs::PermissionsExt;
919        let dir = tempdir().unwrap();
920        let zip_path = dir.path().join("exec.zip");
921        {
922            let file = std::fs::File::create(&zip_path).unwrap();
923            let mut zw = zip::ZipWriter::new(file);
924            let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
925                .compression_method(zip::CompressionMethod::Deflated)
926                .unix_permissions(0o755);
927            zw.start_file("sd-cli", opts).unwrap();
928            zw.write_all(b"#!/bin/sh\n").unwrap();
929            zw.finish().unwrap();
930        }
931        let dest = dir.path().join("out");
932        extract_zip(&zip_path, &dest).unwrap();
933        let mode = std::fs::metadata(dest.join("sd-cli"))
934            .unwrap()
935            .permissions()
936            .mode();
937        assert!(mode & 0o111 != 0, "exec bit must survive: {mode:o}");
938    }
939
940    #[cfg(unix)]
941    #[test]
942    fn library_path_env_points_loader_at_sibling_lib() {
943        let dir = tempdir().unwrap();
944        let sd_cli = dir.path().join(binary_name());
945        std::fs::write(&sd_cli, b"bin").unwrap();
946        // No sibling library yet -> nothing to set.
947        assert!(library_path_env(&sd_cli).is_none());
948        // Drop the platform library next to it.
949        std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
950        let (var, env_dir) = library_path_env(&sd_cli).expect("sibling lib resolved");
951        assert!(var == "LD_LIBRARY_PATH" || var == "DYLD_LIBRARY_PATH");
952        assert_eq!(env_dir, dir.path());
953    }
954
955    #[test]
956    fn vulkan_status_ok_when_loader_loads() {
957        // macOS short-circuits to Ok regardless; elsewhere a loadable
958        // loader is Ok.
959        assert!(vulkan_runtime_status_with(true).is_ok());
960    }
961
962    #[test]
963    fn vulkan_status_errors_with_actionable_remedy_when_missing() {
964        let result = vulkan_runtime_status_with(false);
965        if cfg!(target_os = "macos") {
966            // Metal build: there is no Vulkan loader to miss.
967            assert!(result.is_ok());
968        } else {
969            let err = result.unwrap_err().to_string();
970            assert!(err.contains("Vulkan runtime"), "got: {err}");
971            assert!(
972                err.contains("auto-provision"),
973                "must say we can't auto-provision it: {err}"
974            );
975            // The remedy names the concrete fix for this OS.
976            if cfg!(target_os = "windows") {
977                assert!(err.contains("vulkan-1.dll"), "got: {err}");
978                assert!(err.contains("GPU driver"), "got: {err}");
979            } else {
980                assert!(err.contains("libvulkan1"), "got: {err}");
981                assert!(err.contains("vulkaninfo"), "got: {err}");
982            }
983        }
984    }
985
986    #[cfg(target_os = "windows")]
987    #[test]
988    fn library_path_env_is_none_on_windows() {
989        let dir = tempdir().unwrap();
990        let sd_cli = dir.path().join(binary_name());
991        std::fs::write(&sd_cli, b"bin").unwrap();
992        std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
993        assert!(library_path_env(&sd_cli).is_none());
994    }
995}