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