1use crate::engine::download;
24use anyhow::{anyhow, bail, Context, Result};
25use std::path::{Path, PathBuf};
26use tracing::{info, warn};
27
28const TRACE_TARGET: &str = "studio_worker::engine::sd_provision";
31
32const DEFAULT_RELEASE_TAG: &str = "master-669-2d40a8b";
36
37const RELEASE_ENV: &str = "STUDIO_WORKER_SDCPP_RELEASE";
39const URL_ENV: &str = "STUDIO_WORKER_SDCPP_URL";
41
42pub fn binary_name() -> &'static str {
44 if cfg!(target_os = "windows") {
45 "sd-cli.exe"
46 } else {
47 "sd-cli"
48 }
49}
50
51fn 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
62fn vulkan_loader_name() -> Option<&'static str> {
66 if cfg!(target_os = "windows") {
67 Some("vulkan-1.dll")
68 } else if cfg!(target_os = "macos") {
69 None
70 } else {
71 Some("libvulkan.so.1")
72 }
73}
74
75fn vulkan_remedy() -> &'static str {
79 if cfg!(target_os = "windows") {
80 "install/update your GPU driver (NVIDIA, AMD, or Intel) — it ships \
81 the Vulkan runtime (vulkan-1.dll)"
82 } else {
83 "install the Vulkan loader + a GPU driver, e.g. on Debian/Ubuntu \
84 `sudo apt install libvulkan1 mesa-vulkan-drivers` (plus the \
85 vendor driver for NVIDIA/AMD); verify with `vulkaninfo --summary`"
86 }
87}
88
89#[cfg_attr(coverage_nightly, coverage(off))]
95fn vulkan_loader_loads() -> bool {
96 match vulkan_loader_name() {
97 None => true,
98 Some(name) => unsafe { libloading::Library::new(name).is_ok() },
99 }
100}
101
102fn vulkan_runtime_status_with(loader_loads: bool) -> Result<()> {
108 let Some(loader) = vulkan_loader_name() else {
109 return Ok(()); };
111 if loader_loads {
112 return Ok(());
113 }
114 bail!(
115 "Vulkan runtime not available: the loader `{loader}` could not be \
116 loaded, so stable-diffusion.cpp cannot run on the GPU. We cannot \
117 auto-provision it — {}.",
118 vulkan_remedy()
119 )
120}
121
122#[cfg_attr(coverage_nightly, coverage(off))]
126pub fn vulkan_runtime_status() -> Result<()> {
127 vulkan_runtime_status_with(vulkan_loader_loads())
128}
129
130fn release_tag() -> String {
132 std::env::var(RELEASE_ENV).unwrap_or_else(|_| DEFAULT_RELEASE_TAG.to_string())
133}
134
135fn sha_from_tag(tag: &str) -> Result<&str> {
138 match tag.rsplit_once('-') {
139 Some((_, sha)) if !sha.is_empty() => Ok(sha),
140 _ => Err(anyhow!("release tag {tag:?} has no '-<sha>' segment")),
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum AssetSource {
147 Upstream,
149 SelfHosted,
153}
154
155fn asset_plan(os: &str, arch: &str) -> Result<(AssetSource, &'static str)> {
160 use AssetSource::*;
161 match (os, arch) {
162 ("windows", "x86_64") => Ok((Upstream, "win-vulkan-x64")),
163 ("linux", "x86_64") => Ok((Upstream, "Linux-Ubuntu-24.04-x86_64-vulkan")),
164 ("macos", "aarch64") | ("macos", "x86_64") => Ok((Upstream, "Darwin-macOS-15.7.7-arm64")),
167 ("linux", "aarch64") => Ok((SelfHosted, "Linux-aarch64-vulkan")),
169 _ => bail!(
170 "no prebuilt stable-diffusion.cpp binary for {os}/{arch}; \
171 install sd-cli manually — see docs/operations/sd-cli-install.md"
172 ),
173 }
174}
175
176fn asset_name(sha: &str, suffix: &str) -> String {
179 format!("sd-master-{sha}-bin-{suffix}.zip")
180}
181
182fn self_hosted_tag(upstream_tag: &str) -> String {
184 format!("sdcpp-prebuilt-{upstream_tag}")
185}
186
187fn download_url(tag: &str, os: &str, arch: &str) -> Result<String> {
190 let sha = sha_from_tag(tag)?;
191 let (source, suffix) = asset_plan(os, arch)?;
192 let asset = asset_name(sha, suffix);
193 Ok(match source {
194 AssetSource::Upstream => format!(
195 "https://github.com/leejet/stable-diffusion.cpp/releases/download/{tag}/{asset}"
196 ),
197 AssetSource::SelfHosted => format!(
198 "https://github.com/webbertakken/studio-worker/releases/download/{}/{asset}",
199 self_hosted_tag(tag)
200 ),
201 })
202}
203
204fn resolve_url() -> Result<String> {
208 if let Ok(url) = std::env::var(URL_ENV) {
209 if !url.is_empty() {
210 return Ok(url);
211 }
212 }
213 download_url(&release_tag(), std::env::consts::OS, std::env::consts::ARCH)
214}
215
216pub fn library_path_env(sd_cli: &Path) -> Option<(&'static str, PathBuf)> {
222 if cfg!(target_os = "windows") {
223 return None;
224 }
225 let dir = sd_cli.parent()?;
226 if dir.join(library_name()).is_file() {
227 let var = if cfg!(target_os = "macos") {
228 "DYLD_LIBRARY_PATH"
229 } else {
230 "LD_LIBRARY_PATH"
231 };
232 Some((var, dir.to_path_buf()))
233 } else {
234 None
235 }
236}
237
238#[cfg_attr(coverage_nightly, coverage(off))]
244fn extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<usize> {
245 let file =
246 std::fs::File::open(zip_path).with_context(|| format!("opening {}", zip_path.display()))?;
247 let mut archive = zip::ZipArchive::new(file)
248 .with_context(|| format!("reading zip {}", zip_path.display()))?;
249 std::fs::create_dir_all(dest_dir)
250 .with_context(|| format!("creating {}", dest_dir.display()))?;
251 let mut written = 0usize;
252 for i in 0..archive.len() {
253 let mut entry = archive.by_index(i)?;
254 if entry.is_dir() {
255 continue;
256 }
257 let Some(file_name) = Path::new(entry.name()).file_name().map(|n| n.to_owned()) else {
258 warn!(
259 target: TRACE_TARGET,
260 op = "extract",
261 name = entry.name(),
262 "skipping zip entry with no file name"
263 );
264 continue;
265 };
266 let out = dest_dir.join(&file_name);
267 let mode = entry.unix_mode();
268 let mut writer =
269 std::fs::File::create(&out).with_context(|| format!("creating {}", out.display()))?;
270 std::io::copy(&mut entry, &mut writer)
271 .with_context(|| format!("writing {}", out.display()))?;
272 drop(writer);
273 apply_unix_mode(&out, mode)?;
274 written += 1;
275 }
276 Ok(written)
277}
278
279#[cfg(unix)]
281fn apply_unix_mode(path: &Path, mode: Option<u32>) -> Result<()> {
282 use std::os::unix::fs::PermissionsExt;
283 if let Some(mode) = mode {
284 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
285 .with_context(|| format!("chmod {}", path.display()))?;
286 }
287 Ok(())
288}
289
290#[cfg(not(unix))]
291fn apply_unix_mode(_path: &Path, _mode: Option<u32>) -> Result<()> {
292 Ok(())
293}
294
295#[cfg(unix)]
297fn make_executable(path: &Path) -> Result<()> {
298 use std::os::unix::fs::PermissionsExt;
299 let mut perms = std::fs::metadata(path)
300 .with_context(|| format!("stat {}", path.display()))?
301 .permissions();
302 perms.set_mode(perms.mode() | 0o755);
303 std::fs::set_permissions(path, perms).with_context(|| format!("chmod +x {}", path.display()))
304}
305
306#[cfg(not(unix))]
307fn make_executable(_path: &Path) -> Result<()> {
308 Ok(())
309}
310
311fn install_dir(staging: &Path, target: &Path) -> Result<usize> {
316 std::fs::create_dir_all(target).with_context(|| format!("creating {}", target.display()))?;
317 let mut moved = 0usize;
318 for entry in
319 std::fs::read_dir(staging).with_context(|| format!("reading {}", staging.display()))?
320 {
321 let entry = entry?;
322 if !entry.file_type()?.is_file() {
323 continue;
324 }
325 let from = entry.path();
326 let to = target.join(entry.file_name());
327 if to.exists() {
328 std::fs::remove_file(&to).with_context(|| format!("replacing {}", to.display()))?;
329 }
330 if std::fs::rename(&from, &to).is_err() {
331 std::fs::copy(&from, &to)
332 .with_context(|| format!("copying {} -> {}", from.display(), to.display()))?;
333 }
334 moved += 1;
335 }
336 Ok(moved)
337}
338
339#[cfg_attr(coverage_nightly, coverage(off))]
349pub fn provision(models_root: &Path) -> Result<PathBuf> {
350 let target_dir = models_root.join("bin");
351 let binary = target_dir.join(binary_name());
352 if binary.is_file() {
353 return Ok(binary);
354 }
355
356 let url = resolve_url()?;
357 info!(
358 target: TRACE_TARGET,
359 op = "provision",
360 url = %url,
361 dest = %target_dir.display(),
362 "sd-cli not found; provisioning stable-diffusion.cpp"
363 );
364
365 std::fs::create_dir_all(models_root)
366 .with_context(|| format!("creating {}", models_root.display()))?;
367 let stamp = format!("{}-{}", std::process::id(), now_nanos());
368 let zip_path = models_root.join(format!(".sd-cli-{stamp}.zip"));
369 let staging = models_root.join(format!(".sd-cli-staging-{stamp}"));
370
371 let result = (|| -> Result<PathBuf> {
372 download::download_file(&url, &zip_path)
373 .with_context(|| format!("downloading sd-cli zip from {url}"))?;
374 let count = extract_zip(&zip_path, &staging)?;
375 let staged_binary = staging.join(binary_name());
376 if !staged_binary.is_file() {
377 bail!(
378 "downloaded sd-cli zip from {url} did not contain {} (extracted {count} files)",
379 binary_name()
380 );
381 }
382 install_dir(&staging, &target_dir)?;
383 make_executable(&binary)?;
384 if !binary.is_file() {
385 bail!("sd-cli install left no binary at {}", binary.display());
386 }
387 Ok(binary.clone())
388 })();
389
390 let _ = std::fs::remove_file(&zip_path);
394 let _ = std::fs::remove_dir_all(&staging);
395
396 match &result {
397 Ok(path) => info!(
398 target: TRACE_TARGET,
399 op = "provision",
400 path = %path.display(),
401 "sd-cli provisioned"
402 ),
403 Err(e) => warn!(
404 target: TRACE_TARGET,
405 op = "provision",
406 error = %e,
407 "sd-cli provisioning failed"
408 ),
409 }
410 result
411}
412
413#[cfg_attr(coverage_nightly, coverage(off))]
414fn now_nanos() -> i64 {
415 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use std::io::Write;
422 use tempfile::tempdir;
423
424 #[test]
425 fn sha_from_tag_takes_trailing_segment() {
426 assert_eq!(sha_from_tag("master-669-2d40a8b").unwrap(), "2d40a8b");
427 assert_eq!(sha_from_tag("master-1-abc").unwrap(), "abc");
428 }
429
430 #[test]
431 fn sha_from_tag_rejects_a_tag_without_a_sha() {
432 assert!(sha_from_tag("master").is_err());
433 assert!(sha_from_tag("trailing-").is_err());
434 }
435
436 #[test]
437 fn asset_plan_picks_vulkan_or_universal_for_supported_targets() {
438 use AssetSource::*;
439 assert_eq!(
440 asset_plan("windows", "x86_64").unwrap(),
441 (Upstream, "win-vulkan-x64")
442 );
443 assert_eq!(
444 asset_plan("linux", "x86_64").unwrap(),
445 (Upstream, "Linux-Ubuntu-24.04-x86_64-vulkan")
446 );
447 assert_eq!(
448 asset_plan("macos", "aarch64").unwrap(),
449 (Upstream, "Darwin-macOS-15.7.7-arm64")
450 );
451 }
452
453 #[test]
454 fn asset_plan_makes_intel_mac_and_arm_linux_first_class() {
455 use AssetSource::*;
456 assert_eq!(
458 asset_plan("macos", "x86_64").unwrap(),
459 (Upstream, "Darwin-macOS-15.7.7-arm64")
460 );
461 assert_eq!(
463 asset_plan("linux", "aarch64").unwrap(),
464 (SelfHosted, "Linux-aarch64-vulkan")
465 );
466 }
467
468 #[test]
469 fn asset_plan_rejects_unsupported_targets_with_guidance() {
470 let err = asset_plan("freebsd", "x86_64").unwrap_err().to_string();
471 assert!(err.contains("no prebuilt"), "got: {err}");
472 assert!(
473 err.contains("sd-cli-install.md"),
474 "points to the doc: {err}"
475 );
476 assert!(asset_plan("windows", "aarch64").is_err());
477 }
478
479 #[test]
480 fn asset_name_embeds_sha_and_platform() {
481 assert_eq!(
482 asset_name("2d40a8b", "win-vulkan-x64"),
483 "sd-master-2d40a8b-bin-win-vulkan-x64.zip"
484 );
485 assert_eq!(
486 asset_name("2d40a8b", "Linux-aarch64-vulkan"),
487 "sd-master-2d40a8b-bin-Linux-aarch64-vulkan.zip"
488 );
489 }
490
491 #[test]
492 fn download_url_targets_upstream_for_covered_platforms() {
493 let url = download_url("master-669-2d40a8b", "windows", "x86_64").unwrap();
494 let expected = concat!(
495 "https://github.com/leejet/stable-diffusion.cpp/releases/download/",
496 "master-669-2d40a8b/sd-master-2d40a8b-bin-win-vulkan-x64.zip"
497 );
498 assert_eq!(url, expected);
499 }
500
501 #[test]
502 fn download_url_targets_our_release_for_arm_linux() {
503 let url = download_url("master-669-2d40a8b", "linux", "aarch64").unwrap();
504 let expected = concat!(
505 "https://github.com/webbertakken/studio-worker/releases/download/",
506 "sdcpp-prebuilt-master-669-2d40a8b/",
507 "sd-master-2d40a8b-bin-Linux-aarch64-vulkan.zip"
508 );
509 assert_eq!(url, expected);
510 }
511
512 #[test]
513 fn download_url_uses_universal_darwin_asset_for_intel_mac() {
514 let arm = download_url("master-669-2d40a8b", "macos", "aarch64").unwrap();
515 let intel = download_url("master-669-2d40a8b", "macos", "x86_64").unwrap();
516 assert_eq!(arm, intel, "Intel Macs use the same universal2 asset");
517 assert!(intel.contains("Darwin-macOS-15.7.7-arm64"), "got: {intel}");
518 }
519
520 #[test]
521 fn install_dir_moves_files_and_overwrites() {
522 let staging = tempdir().unwrap();
523 let target = tempdir().unwrap();
524 std::fs::write(staging.path().join("sd-cli"), b"new-binary").unwrap();
525 std::fs::write(staging.path().join("libstable-diffusion.so"), b"lib").unwrap();
526 std::fs::write(target.path().join("sd-cli"), b"old-binary").unwrap();
528
529 let moved = install_dir(staging.path(), target.path()).unwrap();
530 assert_eq!(moved, 2);
531 assert_eq!(
532 std::fs::read(target.path().join("sd-cli")).unwrap(),
533 b"new-binary"
534 );
535 assert_eq!(
536 std::fs::read(target.path().join("libstable-diffusion.so")).unwrap(),
537 b"lib"
538 );
539 assert!(!staging.path().join("sd-cli").exists());
541 }
542
543 #[test]
544 fn extract_zip_flattens_and_defuses_zip_slip() {
545 let dir = tempdir().unwrap();
546 let zip_path = dir.path().join("test.zip");
547 {
550 let file = std::fs::File::create(&zip_path).unwrap();
551 let mut zw = zip::ZipWriter::new(file);
552 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
553 .compression_method(zip::CompressionMethod::Deflated);
554 zw.start_file("sd-cli", opts).unwrap();
555 zw.write_all(b"binary").unwrap();
556 zw.start_file("nested/libstable-diffusion.so", opts)
557 .unwrap();
558 zw.write_all(b"lib").unwrap();
559 zw.start_file("../../escape.txt", opts).unwrap();
560 zw.write_all(b"evil").unwrap();
561 zw.finish().unwrap();
562 }
563 let dest = dir.path().join("out");
564 let count = extract_zip(&zip_path, &dest).unwrap();
565 assert_eq!(count, 3);
566 assert_eq!(std::fs::read(dest.join("sd-cli")).unwrap(), b"binary");
567 assert_eq!(
568 std::fs::read(dest.join("libstable-diffusion.so")).unwrap(),
569 b"lib"
570 );
571 assert!(dest.join("escape.txt").is_file());
574 assert!(!dir.path().join("escape.txt").exists());
575 }
576
577 #[cfg(unix)]
578 #[test]
579 fn extract_zip_preserves_exec_bit() {
580 use std::os::unix::fs::PermissionsExt;
581 let dir = tempdir().unwrap();
582 let zip_path = dir.path().join("exec.zip");
583 {
584 let file = std::fs::File::create(&zip_path).unwrap();
585 let mut zw = zip::ZipWriter::new(file);
586 let opts: zip::write::FileOptions<()> = zip::write::FileOptions::default()
587 .compression_method(zip::CompressionMethod::Deflated)
588 .unix_permissions(0o755);
589 zw.start_file("sd-cli", opts).unwrap();
590 zw.write_all(b"#!/bin/sh\n").unwrap();
591 zw.finish().unwrap();
592 }
593 let dest = dir.path().join("out");
594 extract_zip(&zip_path, &dest).unwrap();
595 let mode = std::fs::metadata(dest.join("sd-cli"))
596 .unwrap()
597 .permissions()
598 .mode();
599 assert!(mode & 0o111 != 0, "exec bit must survive: {mode:o}");
600 }
601
602 #[cfg(unix)]
603 #[test]
604 fn library_path_env_points_loader_at_sibling_lib() {
605 let dir = tempdir().unwrap();
606 let sd_cli = dir.path().join(binary_name());
607 std::fs::write(&sd_cli, b"bin").unwrap();
608 assert!(library_path_env(&sd_cli).is_none());
610 std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
612 let (var, env_dir) = library_path_env(&sd_cli).expect("sibling lib resolved");
613 assert!(var == "LD_LIBRARY_PATH" || var == "DYLD_LIBRARY_PATH");
614 assert_eq!(env_dir, dir.path());
615 }
616
617 #[test]
618 fn vulkan_status_ok_when_loader_loads() {
619 assert!(vulkan_runtime_status_with(true).is_ok());
622 }
623
624 #[test]
625 fn vulkan_status_errors_with_actionable_remedy_when_missing() {
626 let result = vulkan_runtime_status_with(false);
627 if cfg!(target_os = "macos") {
628 assert!(result.is_ok());
630 } else {
631 let err = result.unwrap_err().to_string();
632 assert!(err.contains("Vulkan runtime"), "got: {err}");
633 assert!(
634 err.contains("auto-provision"),
635 "must say we can't auto-provision it: {err}"
636 );
637 if cfg!(target_os = "windows") {
639 assert!(err.contains("vulkan-1.dll"), "got: {err}");
640 assert!(err.contains("GPU driver"), "got: {err}");
641 } else {
642 assert!(err.contains("libvulkan1"), "got: {err}");
643 assert!(err.contains("vulkaninfo"), "got: {err}");
644 }
645 }
646 }
647
648 #[cfg(target_os = "windows")]
649 #[test]
650 fn library_path_env_is_none_on_windows() {
651 let dir = tempdir().unwrap();
652 let sd_cli = dir.path().join(binary_name());
653 std::fs::write(&sd_cli, b"bin").unwrap();
654 std::fs::write(dir.path().join(library_name()), b"lib").unwrap();
655 assert!(library_path_env(&sd_cli).is_none());
656 }
657}