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 release tag to provision — env override or the pinned default.
63fn release_tag() -> String {
64    std::env::var(RELEASE_ENV).unwrap_or_else(|_| DEFAULT_RELEASE_TAG.to_string())
65}
66
67/// The short commit sha embedded in asset filenames is the trailing
68/// `-`-segment of the release tag (`master-669-2d40a8b` -> `2d40a8b`).
69fn sha_from_tag(tag: &str) -> Result<&str> {
70    match tag.rsplit_once('-') {
71        Some((_, sha)) if !sha.is_empty() => Ok(sha),
72        _ => Err(anyhow!("release tag {tag:?} has no '-<sha>' segment")),
73    }
74}
75
76/// Platform asset suffix (the part between `bin-` and `.zip`).  Vulkan
77/// is the universal GPU backend — one build serves NVIDIA, AMD and
78/// Intel — so we pick it for every supported target.
79fn asset_suffix(os: &str, arch: &str) -> Result<&'static str> {
80    match (os, arch) {
81        ("windows", "x86_64") => Ok("win-vulkan-x64"),
82        ("linux", "x86_64") => Ok("Linux-Ubuntu-24.04-x86_64-vulkan"),
83        ("macos", "aarch64") => Ok("Darwin-macOS-15.7.7-arm64"),
84        _ => bail!(
85            "no prebuilt stable-diffusion.cpp binary for {os}/{arch}; \
86             install sd-cli manually — see docs/operations/sd-cli-install.md"
87        ),
88    }
89}
90
91/// Build the asset filename for `tag` on `os`/`arch`.
92fn asset_name(tag: &str, os: &str, arch: &str) -> Result<String> {
93    let sha = sha_from_tag(tag)?;
94    let suffix = asset_suffix(os, arch)?;
95    Ok(format!("sd-master-{sha}-bin-{suffix}.zip"))
96}
97
98/// The full release-download URL for `tag` on `os`/`arch`.
99fn download_url(tag: &str, os: &str, arch: &str) -> Result<String> {
100    let asset = asset_name(tag, os, arch)?;
101    Ok(format!(
102        "https://github.com/leejet/stable-diffusion.cpp/releases/download/{tag}/{asset}"
103    ))
104}
105
106/// Resolve the zip URL to fetch: the `STUDIO_WORKER_SDCPP_URL`
107/// override if set, otherwise the pinned/overridden release for this
108/// host's platform.
109fn resolve_url() -> Result<String> {
110    if let Ok(url) = std::env::var(URL_ENV) {
111        if !url.is_empty() {
112            return Ok(url);
113        }
114    }
115    download_url(&release_tag(), std::env::consts::OS, std::env::consts::ARCH)
116}
117
118/// If a stable-diffusion shared library sits next to `sd_cli`, return
119/// the `(env-var, dir)` the per-job `Command` must set so the dynamic
120/// linker finds it.  Returns `None` on Windows (sibling DLLs resolve
121/// automatically) and when no sibling library is present (e.g. an
122/// operator's wrapper-script install manages its own load path).
123pub fn library_path_env(sd_cli: &Path) -> Option<(&'static str, PathBuf)> {
124    if cfg!(target_os = "windows") {
125        return None;
126    }
127    let dir = sd_cli.parent()?;
128    if dir.join(library_name()).is_file() {
129        let var = if cfg!(target_os = "macos") {
130            "DYLD_LIBRARY_PATH"
131        } else {
132            "LD_LIBRARY_PATH"
133        };
134        Some((var, dir.to_path_buf()))
135    } else {
136        None
137    }
138}
139
140/// Extract every file in the zip at `zip_path` into `dest_dir`,
141/// flattened to bare file names.  Flattening is also the zip-slip
142/// defence: `Path::file_name` drops every directory component, so a
143/// crafted `../../etc/passwd` entry can only ever land as `passwd`
144/// inside `dest_dir`.  Returns the number of files written.
145#[cfg_attr(coverage_nightly, coverage(off))]
146fn extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<usize> {
147    let file =
148        std::fs::File::open(zip_path).with_context(|| format!("opening {}", zip_path.display()))?;
149    let mut archive = zip::ZipArchive::new(file)
150        .with_context(|| format!("reading zip {}", zip_path.display()))?;
151    std::fs::create_dir_all(dest_dir)
152        .with_context(|| format!("creating {}", dest_dir.display()))?;
153    let mut written = 0usize;
154    for i in 0..archive.len() {
155        let mut entry = archive.by_index(i)?;
156        if entry.is_dir() {
157            continue;
158        }
159        let Some(file_name) = Path::new(entry.name()).file_name().map(|n| n.to_owned()) else {
160            warn!(
161                target: TRACE_TARGET,
162                op = "extract",
163                name = entry.name(),
164                "skipping zip entry with no file name"
165            );
166            continue;
167        };
168        let out = dest_dir.join(&file_name);
169        let mode = entry.unix_mode();
170        let mut writer =
171            std::fs::File::create(&out).with_context(|| format!("creating {}", out.display()))?;
172        std::io::copy(&mut entry, &mut writer)
173            .with_context(|| format!("writing {}", out.display()))?;
174        drop(writer);
175        apply_unix_mode(&out, mode)?;
176        written += 1;
177    }
178    Ok(written)
179}
180
181/// Apply the zip entry's unix mode when present.  No-op off unix.
182#[cfg(unix)]
183fn apply_unix_mode(path: &Path, mode: Option<u32>) -> Result<()> {
184    use std::os::unix::fs::PermissionsExt;
185    if let Some(mode) = mode {
186        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
187            .with_context(|| format!("chmod {}", path.display()))?;
188    }
189    Ok(())
190}
191
192#[cfg(not(unix))]
193fn apply_unix_mode(_path: &Path, _mode: Option<u32>) -> Result<()> {
194    Ok(())
195}
196
197/// Ensure `path` is executable (owner +x) on unix.  No-op off unix.
198#[cfg(unix)]
199fn make_executable(path: &Path) -> Result<()> {
200    use std::os::unix::fs::PermissionsExt;
201    let mut perms = std::fs::metadata(path)
202        .with_context(|| format!("stat {}", path.display()))?
203        .permissions();
204    perms.set_mode(perms.mode() | 0o755);
205    std::fs::set_permissions(path, perms).with_context(|| format!("chmod +x {}", path.display()))
206}
207
208#[cfg(not(unix))]
209fn make_executable(_path: &Path) -> Result<()> {
210    Ok(())
211}
212
213/// Publish every file from `staging` into `target` (created if
214/// needed), overwriting existing files.  Prefers an intra-filesystem
215/// rename (instant for the ~100 MB library) and falls back to a copy
216/// across filesystems.
217fn install_dir(staging: &Path, target: &Path) -> Result<usize> {
218    std::fs::create_dir_all(target).with_context(|| format!("creating {}", target.display()))?;
219    let mut moved = 0usize;
220    for entry in
221        std::fs::read_dir(staging).with_context(|| format!("reading {}", staging.display()))?
222    {
223        let entry = entry?;
224        if !entry.file_type()?.is_file() {
225            continue;
226        }
227        let from = entry.path();
228        let to = target.join(entry.file_name());
229        if to.exists() {
230            std::fs::remove_file(&to).with_context(|| format!("replacing {}", to.display()))?;
231        }
232        if std::fs::rename(&from, &to).is_err() {
233            std::fs::copy(&from, &to)
234                .with_context(|| format!("copying {} -> {}", from.display(), to.display()))?;
235        }
236        moved += 1;
237    }
238    Ok(moved)
239}
240
241/// Ensure `sd-cli` is installed under `<models_root>/bin/`, downloading
242/// and extracting the platform's stable-diffusion.cpp build when it's
243/// missing.  Returns the resolved binary path.  Idempotent: a binary
244/// already present short-circuits the download.
245///
246/// Excluded from coverage: drives a real network download + filesystem
247/// extraction.  The pure pieces it composes ([`asset_name`],
248/// [`download_url`], [`install_dir`], [`library_path_env`]) and the
249/// full path against a served fake zip are covered by tests.
250#[cfg_attr(coverage_nightly, coverage(off))]
251pub fn provision(models_root: &Path) -> Result<PathBuf> {
252    let target_dir = models_root.join("bin");
253    let binary = target_dir.join(binary_name());
254    if binary.is_file() {
255        return Ok(binary);
256    }
257
258    let url = resolve_url()?;
259    info!(
260        target: TRACE_TARGET,
261        op = "provision",
262        url = %url,
263        dest = %target_dir.display(),
264        "sd-cli not found; provisioning stable-diffusion.cpp"
265    );
266
267    std::fs::create_dir_all(models_root)
268        .with_context(|| format!("creating {}", models_root.display()))?;
269    let stamp = format!("{}-{}", std::process::id(), now_nanos());
270    let zip_path = models_root.join(format!(".sd-cli-{stamp}.zip"));
271    let staging = models_root.join(format!(".sd-cli-staging-{stamp}"));
272
273    let result = (|| -> Result<PathBuf> {
274        download::download_file(&url, &zip_path)
275            .with_context(|| format!("downloading sd-cli zip from {url}"))?;
276        let count = extract_zip(&zip_path, &staging)?;
277        let staged_binary = staging.join(binary_name());
278        if !staged_binary.is_file() {
279            bail!(
280                "downloaded sd-cli zip from {url} did not contain {} (extracted {count} files)",
281                binary_name()
282            );
283        }
284        install_dir(&staging, &target_dir)?;
285        make_executable(&binary)?;
286        if !binary.is_file() {
287            bail!("sd-cli install left no binary at {}", binary.display());
288        }
289        Ok(binary.clone())
290    })();
291
292    // Best-effort cleanup of the scratch zip + staging dir on every
293    // exit path so a failed provision can't leave half-extracted
294    // multi-hundred-MB files filling the disk.
295    let _ = std::fs::remove_file(&zip_path);
296    let _ = std::fs::remove_dir_all(&staging);
297
298    match &result {
299        Ok(path) => info!(
300            target: TRACE_TARGET,
301            op = "provision",
302            path = %path.display(),
303            "sd-cli provisioned"
304        ),
305        Err(e) => warn!(
306            target: TRACE_TARGET,
307            op = "provision",
308            error = %e,
309            "sd-cli provisioning failed"
310        ),
311    }
312    result
313}
314
315#[cfg_attr(coverage_nightly, coverage(off))]
316fn now_nanos() -> i64 {
317    chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::io::Write;
324    use tempfile::tempdir;
325
326    #[test]
327    fn sha_from_tag_takes_trailing_segment() {
328        assert_eq!(sha_from_tag("master-669-2d40a8b").unwrap(), "2d40a8b");
329        assert_eq!(sha_from_tag("master-1-abc").unwrap(), "abc");
330    }
331
332    #[test]
333    fn sha_from_tag_rejects_a_tag_without_a_sha() {
334        assert!(sha_from_tag("master").is_err());
335        assert!(sha_from_tag("trailing-").is_err());
336    }
337
338    #[test]
339    fn asset_suffix_picks_vulkan_for_supported_targets() {
340        assert_eq!(asset_suffix("windows", "x86_64").unwrap(), "win-vulkan-x64");
341        assert_eq!(
342            asset_suffix("linux", "x86_64").unwrap(),
343            "Linux-Ubuntu-24.04-x86_64-vulkan"
344        );
345        assert_eq!(
346            asset_suffix("macos", "aarch64").unwrap(),
347            "Darwin-macOS-15.7.7-arm64"
348        );
349    }
350
351    #[test]
352    fn asset_suffix_rejects_unsupported_targets_with_guidance() {
353        let err = asset_suffix("linux", "aarch64").unwrap_err().to_string();
354        assert!(err.contains("no prebuilt"), "got: {err}");
355        assert!(
356            err.contains("sd-cli-install.md"),
357            "points to the doc: {err}"
358        );
359        assert!(asset_suffix("freebsd", "x86_64").is_err());
360        assert!(asset_suffix("macos", "x86_64").is_err());
361    }
362
363    #[test]
364    fn asset_name_embeds_sha_and_platform() {
365        assert_eq!(
366            asset_name("master-669-2d40a8b", "windows", "x86_64").unwrap(),
367            "sd-master-2d40a8b-bin-win-vulkan-x64.zip"
368        );
369        assert_eq!(
370            asset_name("master-669-2d40a8b", "linux", "x86_64").unwrap(),
371            "sd-master-2d40a8b-bin-Linux-Ubuntu-24.04-x86_64-vulkan.zip"
372        );
373    }
374
375    #[test]
376    fn download_url_targets_the_upstream_release() {
377        let url = download_url("master-669-2d40a8b", "windows", "x86_64").unwrap();
378        let expected = concat!(
379            "https://github.com/leejet/stable-diffusion.cpp/releases/download/",
380            "master-669-2d40a8b/sd-master-2d40a8b-bin-win-vulkan-x64.zip"
381        );
382        assert_eq!(url, expected);
383    }
384
385    #[test]
386    fn install_dir_moves_files_and_overwrites() {
387        let staging = tempdir().unwrap();
388        let target = tempdir().unwrap();
389        std::fs::write(staging.path().join("sd-cli"), b"new-binary").unwrap();
390        std::fs::write(staging.path().join("libstable-diffusion.so"), b"lib").unwrap();
391        // A stale file in target must be overwritten, not duplicated.
392        std::fs::write(target.path().join("sd-cli"), b"old-binary").unwrap();
393
394        let moved = install_dir(staging.path(), target.path()).unwrap();
395        assert_eq!(moved, 2);
396        assert_eq!(
397            std::fs::read(target.path().join("sd-cli")).unwrap(),
398            b"new-binary"
399        );
400        assert_eq!(
401            std::fs::read(target.path().join("libstable-diffusion.so")).unwrap(),
402            b"lib"
403        );
404        // Files were moved, so staging is now empty of them.
405        assert!(!staging.path().join("sd-cli").exists());
406    }
407
408    #[test]
409    fn extract_zip_flattens_and_defuses_zip_slip() {
410        let dir = tempdir().unwrap();
411        let zip_path = dir.path().join("test.zip");
412        // Build a zip with a nested + a path-traversal entry; both must
413        // land flat inside dest, never escaping it.
414        {
415            let file = std::fs::File::create(&zip_path).unwrap();
416            let mut zw = zip::ZipWriter::new(file);
417            let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
418                .compression_method(zip::CompressionMethod::Deflated);
419            zw.start_file("sd-cli", opts).unwrap();
420            zw.write_all(b"binary").unwrap();
421            zw.start_file("nested/libstable-diffusion.so", opts)
422                .unwrap();
423            zw.write_all(b"lib").unwrap();
424            zw.start_file("../../escape.txt", opts).unwrap();
425            zw.write_all(b"evil").unwrap();
426            zw.finish().unwrap();
427        }
428        let dest = dir.path().join("out");
429        let count = extract_zip(&zip_path, &dest).unwrap();
430        assert_eq!(count, 3);
431        assert_eq!(std::fs::read(dest.join("sd-cli")).unwrap(), b"binary");
432        assert_eq!(
433            std::fs::read(dest.join("libstable-diffusion.so")).unwrap(),
434            b"lib"
435        );
436        // The traversal entry was flattened into dest, not written to a
437        // parent directory.
438        assert!(dest.join("escape.txt").is_file());
439        assert!(!dir.path().join("escape.txt").exists());
440    }
441
442    #[cfg(unix)]
443    #[test]
444    fn extract_zip_preserves_exec_bit() {
445        use std::os::unix::fs::PermissionsExt;
446        let dir = tempdir().unwrap();
447        let zip_path = dir.path().join("exec.zip");
448        {
449            let file = std::fs::File::create(&zip_path).unwrap();
450            let mut zw = zip::ZipWriter::new(file);
451            let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
452                .compression_method(zip::CompressionMethod::Deflated)
453                .unix_permissions(0o755);
454            zw.start_file("sd-cli", opts).unwrap();
455            zw.write_all(b"#!/bin/sh\n").unwrap();
456            zw.finish().unwrap();
457        }
458        let dest = dir.path().join("out");
459        extract_zip(&zip_path, &dest).unwrap();
460        let mode = std::fs::metadata(dest.join("sd-cli"))
461            .unwrap()
462            .permissions()
463            .mode();
464        assert!(mode & 0o111 != 0, "exec bit must survive: {mode:o}");
465    }
466
467    #[cfg(unix)]
468    #[test]
469    fn library_path_env_points_loader_at_sibling_lib() {
470        let dir = tempdir().unwrap();
471        let sd_cli = dir.path().join(binary_name());
472        std::fs::write(&sd_cli, b"bin").unwrap();
473        // No sibling library yet -> nothing to set.
474        assert!(library_path_env(&sd_cli).is_none());
475        // Drop the platform library next to it.
476        std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
477        let (var, env_dir) = library_path_env(&sd_cli).expect("sibling lib resolved");
478        assert!(var == "LD_LIBRARY_PATH" || var == "DYLD_LIBRARY_PATH");
479        assert_eq!(env_dir, dir.path());
480    }
481
482    #[cfg(target_os = "windows")]
483    #[test]
484    fn library_path_env_is_none_on_windows() {
485        let dir = tempdir().unwrap();
486        let sd_cli = dir.path().join(binary_name());
487        std::fs::write(&sd_cli, b"bin").unwrap();
488        std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
489        assert!(library_path_env(&sd_cli).is_none());
490    }
491}