1use std::fs::{self, File, OpenOptions};
4use std::io::Write;
5use std::path::{Component, Path, PathBuf};
6use std::thread;
7use std::time::{Duration, Instant};
8
9use flate2::read::GzDecoder;
10use fs2::FileExt;
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13use uuid::Uuid;
14
15use crate::artifact::{ArtifactError, ArtifactFetcher, ArtifactSpec, hash_file};
16use crate::hardware::{
17 Architecture, HardwareProfile, OperatingSystem, RuntimeBackend, RuntimePlatform,
18};
19
20pub const LLAMA_CPP_RELEASE: &str = "b10630";
22pub const LLAMA_CPP_COMMIT: &str = "d222767c7a6516559a3f49e7721b6c6b1acc87b4";
23pub const LLAMA_CPP_VERSION: &str = "0.3.0-dev";
24
25const MANIFEST_FILE: &str = ".falsegreen-runtime-manifest.json";
26const MAX_EXTRACTED_BYTES: u64 = 4 * 1024 * 1024 * 1024;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30enum ArchiveFormat {
31 TarGz,
32 Zip,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36struct RuntimeArtifact {
37 spec: ArtifactSpec,
38 platform: RuntimePlatform,
39 backend: RuntimeBackend,
40 archive_format: ArchiveFormat,
41 server_relative_path: PathBuf,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct RuntimeInstall {
46 pub root: PathBuf,
47 pub server_executable: PathBuf,
48 pub artifact_file_name: String,
49 pub artifact_sha256: String,
50 pub platform: RuntimePlatform,
51 pub backend: RuntimeBackend,
52 pub release: String,
53 pub commit: String,
54 pub reused: bool,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct RuntimeCacheInspection {
59 pub archive_path: PathBuf,
60 pub install_root: PathBuf,
61 pub server_executable: PathBuf,
62 pub artifact_file_name: String,
63 pub artifact_sha256: String,
64 pub artifact_size_bytes: u64,
65 pub archive_present: bool,
66 pub archive_digest_verified: Option<bool>,
67 pub install_present: bool,
68 pub install_verified: bool,
69}
70
71#[derive(Debug, Clone)]
72pub struct LlamaRuntimeManager {
73 cache_root: PathBuf,
74 fetcher: ArtifactFetcher,
75 lock_timeout: Duration,
76}
77
78impl LlamaRuntimeManager {
79 #[must_use]
80 pub fn new(cache_root: impl Into<PathBuf>, fetcher: ArtifactFetcher) -> Self {
81 Self {
82 cache_root: cache_root.into(),
83 fetcher,
84 lock_timeout: Duration::from_secs(60),
85 }
86 }
87
88 #[must_use]
89 pub const fn with_lock_timeout(mut self, timeout: Duration) -> Self {
90 self.lock_timeout = timeout;
91 self
92 }
93
94 pub fn ensure(
95 &self,
96 hardware: &HardwareProfile,
97 preferred_backend: Option<RuntimeBackend>,
98 ) -> Result<RuntimeInstall, RuntimeError> {
99 self.ensure_for(
100 hardware.platform,
101 preferred_backend.unwrap_or_else(|| hardware.recommended_backend()),
102 )
103 }
104
105 pub fn ensure_for(
106 &self,
107 platform: RuntimePlatform,
108 backend: RuntimeBackend,
109 ) -> Result<RuntimeInstall, RuntimeError> {
110 let artifact = runtime_artifact(platform, backend)?;
111 let archive_path = self
112 .cache_root
113 .join("downloads")
114 .join("llama.cpp")
115 .join(LLAMA_CPP_RELEASE)
116 .join(&artifact.spec.file_name);
117 self.fetcher.ensure_file(&artifact.spec, &archive_path)?;
118
119 let install_parent = self
120 .cache_root
121 .join("runtimes")
122 .join("llama.cpp")
123 .join(LLAMA_CPP_RELEASE);
124 fs::create_dir_all(&install_parent)
125 .map_err(|source| io_error("create runtime cache", &install_parent, source))?;
126 let install_name = format!("{}-{}", platform.cache_key(), backend.cache_key());
127 let install_root = install_parent.join(install_name);
128 let lock_path = install_root.with_extension("lock");
129 let _lock = DirectoryLock::acquire(&lock_path, self.lock_timeout)?;
130
131 if install_root.exists() {
132 match validate_install(&install_root, &artifact) {
133 Ok(server_executable) => {
134 return Ok(RuntimeInstall {
135 root: install_root,
136 server_executable,
137 artifact_file_name: artifact.spec.file_name.clone(),
138 artifact_sha256: artifact.spec.sha256.clone(),
139 platform,
140 backend,
141 release: LLAMA_CPP_RELEASE.to_owned(),
142 commit: LLAMA_CPP_COMMIT.to_owned(),
143 reused: true,
144 });
145 }
146 Err(RuntimeError::InvalidInstall(_)) => quarantine_directory(&install_root)?,
147 Err(error) => return Err(error),
148 }
149 }
150
151 let staging = install_parent.join(format!(".staging-{}", Uuid::new_v4()));
152 let mut staging_guard = StagingDirectory::create(staging)?;
153 extract_archive(&archive_path, staging_guard.path(), artifact.archive_format)?;
154 let server_executable = staging_guard.path().join(&artifact.server_relative_path);
155 if !server_executable.is_file() {
156 return Err(RuntimeError::InvalidInstall(format!(
157 "archive did not contain expected server executable {}",
158 artifact.server_relative_path.display()
159 )));
160 }
161 make_executable(&server_executable)?;
162 let entries = collect_entries(staging_guard.path())?;
163 let manifest = InstallManifest {
164 schema_version: 1,
165 release: LLAMA_CPP_RELEASE.to_owned(),
166 commit: LLAMA_CPP_COMMIT.to_owned(),
167 platform,
168 backend,
169 archive_sha256: artifact.spec.sha256.to_ascii_lowercase(),
170 server_relative_path: artifact.server_relative_path.clone(),
171 entries,
172 };
173 write_manifest(staging_guard.path(), &manifest)?;
174 staging_guard.publish(&install_root)?;
175 let server_executable = install_root.join(&artifact.server_relative_path);
176 validate_install(&install_root, &artifact)?;
177
178 Ok(RuntimeInstall {
179 root: install_root,
180 server_executable,
181 artifact_file_name: artifact.spec.file_name,
182 artifact_sha256: artifact.spec.sha256,
183 platform,
184 backend,
185 release: LLAMA_CPP_RELEASE.to_owned(),
186 commit: LLAMA_CPP_COMMIT.to_owned(),
187 reused: false,
188 })
189 }
190
191 pub fn inspect_for(
193 &self,
194 platform: RuntimePlatform,
195 backend: RuntimeBackend,
196 verify_archive_digest: bool,
197 ) -> Result<RuntimeCacheInspection, RuntimeError> {
198 let artifact = runtime_artifact(platform, backend)?;
199 let archive_path = self
200 .cache_root
201 .join("downloads")
202 .join("llama.cpp")
203 .join(LLAMA_CPP_RELEASE)
204 .join(&artifact.spec.file_name);
205 let install_root = self
206 .cache_root
207 .join("runtimes")
208 .join("llama.cpp")
209 .join(LLAMA_CPP_RELEASE)
210 .join(format!("{}-{}", platform.cache_key(), backend.cache_key()));
211 let archive_present = fs::symlink_metadata(&archive_path)
212 .is_ok_and(|metadata| metadata.file_type().is_file());
213 let archive_digest_verified = (archive_present && verify_archive_digest)
214 .then(|| crate::artifact::verify_file(&archive_path, &artifact.spec).is_ok());
215 let install_present =
216 fs::symlink_metadata(&install_root).is_ok_and(|metadata| metadata.file_type().is_dir());
217 let validated_server = install_present
218 .then(|| validate_install(&install_root, &artifact))
219 .transpose();
220 let (server_executable, install_verified) = match validated_server {
221 Ok(Some(path)) => (path, true),
222 Ok(None) | Err(_) => (install_root.join(&artifact.server_relative_path), false),
223 };
224 Ok(RuntimeCacheInspection {
225 archive_path,
226 install_root,
227 server_executable,
228 artifact_file_name: artifact.spec.file_name,
229 artifact_sha256: artifact.spec.sha256,
230 artifact_size_bytes: artifact.spec.size_bytes,
231 archive_present,
232 archive_digest_verified,
233 install_present,
234 install_verified,
235 })
236 }
237}
238
239#[derive(Debug, Error)]
240pub enum RuntimeError {
241 #[error(transparent)]
242 Artifact(#[from] ArtifactError),
243 #[error("no pinned llama-server artifact for {platform} with {backend:?} backend")]
244 UnsupportedTarget {
245 platform: RuntimePlatform,
246 backend: RuntimeBackend,
247 },
248 #[error("runtime archive contains an unsafe or unsupported entry: {0}")]
249 UnsafeArchive(String),
250 #[error("managed runtime installation is not trustworthy: {0}")]
251 InvalidInstall(String),
252 #[error("timed out waiting for runtime install lock {path} after {seconds} seconds")]
253 LockTimeout { path: PathBuf, seconds: u64 },
254 #[error("could not {operation} at {path}: {source}")]
255 Io {
256 operation: &'static str,
257 path: PathBuf,
258 #[source]
259 source: std::io::Error,
260 },
261 #[error("could not decode runtime archive: {0}")]
262 Archive(String),
263 #[error("could not serialize runtime manifest: {0}")]
264 Manifest(#[from] serde_json::Error),
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268struct InstallManifest {
269 schema_version: u32,
270 release: String,
271 commit: String,
272 platform: RuntimePlatform,
273 backend: RuntimeBackend,
274 archive_sha256: String,
275 server_relative_path: PathBuf,
276 entries: Vec<InstalledEntry>,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280#[serde(tag = "kind", rename_all = "snake_case")]
281enum InstalledEntry {
282 File {
283 path: PathBuf,
284 sha256: String,
285 size_bytes: u64,
286 },
287 Symlink {
288 path: PathBuf,
289 target: PathBuf,
290 },
291}
292
293fn runtime_artifact(
294 platform: RuntimePlatform,
295 backend: RuntimeBackend,
296) -> Result<RuntimeArtifact, RuntimeError> {
297 let (name, digest, size, format) = match (platform.os, platform.architecture, backend) {
298 (OperatingSystem::Linux, Architecture::X86_64, RuntimeBackend::Cpu) => (
299 "llama-b10630-bin-ubuntu-x64.tar.gz",
300 "ab5e2c472ae317a8e2210272a25569a74c054a05e196feef200bfef270d2204b",
301 16_292_161,
302 ArchiveFormat::TarGz,
303 ),
304 (OperatingSystem::Linux, Architecture::X86_64, RuntimeBackend::Rocm) => (
305 "llama-b10630-bin-ubuntu-rocm-7.14-x64.tar.gz",
306 "a44a8649b44398465ad49bccd1eda39e7019caade63f918c220e05e3572aaaee",
307 213_952_218,
308 ArchiveFormat::TarGz,
309 ),
310 (OperatingSystem::Linux, Architecture::X86_64, RuntimeBackend::Vulkan) => (
311 "llama-b10630-bin-ubuntu-vulkan-x64.tar.gz",
312 "d2d3da1bab8c3b85dfd130a8bcbec9ca83abc584000ee688d92c296550666353",
313 32_914_728,
314 ArchiveFormat::TarGz,
315 ),
316 (OperatingSystem::Linux, Architecture::Aarch64, RuntimeBackend::Cpu) => (
317 "llama-b10630-bin-ubuntu-arm64.tar.gz",
318 "783bd5dd828cdb9a26a26b85ce9adc2951f2be0c37e0b9593059116431a8e771",
319 13_042_152,
320 ArchiveFormat::TarGz,
321 ),
322 (OperatingSystem::Linux, Architecture::Aarch64, RuntimeBackend::Vulkan) => (
323 "llama-b10630-bin-ubuntu-vulkan-arm64.tar.gz",
324 "6dee633ff7f9f5e2279eeda15bd464b5b3894af5bd602b3a2d7c7da27cae7e57",
325 26_779_926,
326 ArchiveFormat::TarGz,
327 ),
328 (OperatingSystem::Macos, Architecture::X86_64, RuntimeBackend::Metal) => (
329 "llama-b10630-bin-macos-x64.tar.gz",
330 "49100754ff4211e6c0e558fe3e9384d59eca51ce1c70673a632f788c6b58d800",
331 11_034_413,
332 ArchiveFormat::TarGz,
333 ),
334 (OperatingSystem::Macos, Architecture::Aarch64, RuntimeBackend::Metal) => (
335 "llama-b10630-bin-macos-arm64.tar.gz",
336 "3394752534fc7cb62bf861868b750a704ead264ac9fac7083e7d49e835a19a10",
337 10_963_541,
338 ArchiveFormat::TarGz,
339 ),
340 (OperatingSystem::Windows, Architecture::X86_64, RuntimeBackend::Cpu) => (
341 "llama-b10630-bin-win-cpu-x64.zip",
342 "45fe4ffb81386872ee3cbb1ed83473df1ed3ce68edcb3d2d0bd31c5a16c4f416",
343 18_067_881,
344 ArchiveFormat::Zip,
345 ),
346 (OperatingSystem::Windows, Architecture::X86_64, RuntimeBackend::Rocm) => (
347 "llama-b10630-bin-win-rocm-7.14-x64.zip",
348 "fb067cc91e8a09885d3dbb9dc9ebc67279c1e11a078a0ebf8aa05c4cc8b6b0bb",
349 196_227_720,
350 ArchiveFormat::Zip,
351 ),
352 (OperatingSystem::Windows, Architecture::X86_64, RuntimeBackend::Vulkan) => (
353 "llama-b10630-bin-win-vulkan-x64.zip",
354 "09e9afa74194522465110df724250dae40393f66b42a8ce8901c4dab6ed17c1f",
355 34_403_736,
356 ArchiveFormat::Zip,
357 ),
358 (OperatingSystem::Windows, Architecture::Aarch64, RuntimeBackend::Cpu) => (
359 "llama-b10630-bin-win-cpu-arm64.zip",
360 "c7e0e822fc3cc967e4111b6ab0685ffa11ee2f62923b6c35527e601f8c9e6da4",
361 11_846_985,
362 ArchiveFormat::Zip,
363 ),
364 _ => return Err(RuntimeError::UnsupportedTarget { platform, backend }),
365 };
366 let server_relative_path = if platform.os == OperatingSystem::Windows {
367 PathBuf::from("llama-server.exe")
368 } else {
369 PathBuf::from(format!("llama-{LLAMA_CPP_RELEASE}/llama-server"))
370 };
371 Ok(RuntimeArtifact {
372 spec: ArtifactSpec {
373 id: "llama.cpp".to_owned(),
374 version: LLAMA_CPP_RELEASE.to_owned(),
375 file_name: name.to_owned(),
376 url: format!(
377 "https://github.com/ggml-org/llama.cpp/releases/download/{LLAMA_CPP_RELEASE}/{name}"
378 ),
379 sha256: digest.to_owned(),
380 size_bytes: size,
381 },
382 platform,
383 backend,
384 archive_format: format,
385 server_relative_path,
386 })
387}
388
389fn extract_archive(
390 archive_path: &Path,
391 destination: &Path,
392 format: ArchiveFormat,
393) -> Result<(), RuntimeError> {
394 match format {
395 ArchiveFormat::TarGz => extract_tar_gz(archive_path, destination),
396 ArchiveFormat::Zip => extract_zip(archive_path, destination),
397 }
398}
399
400fn extract_tar_gz(archive_path: &Path, destination: &Path) -> Result<(), RuntimeError> {
401 let file = File::open(archive_path)
402 .map_err(|source| io_error("open runtime archive", archive_path, source))?;
403 let mut archive = tar::Archive::new(GzDecoder::new(file));
404 let entries = archive
405 .entries()
406 .map_err(|error| RuntimeError::Archive(error.to_string()))?;
407 let mut extracted = 0_u64;
408 for entry in entries {
409 let mut entry = entry.map_err(|error| RuntimeError::Archive(error.to_string()))?;
410 let relative = entry
411 .path()
412 .map_err(|error| RuntimeError::Archive(error.to_string()))?
413 .into_owned();
414 validate_relative_path(&relative)?;
415 let kind = entry.header().entry_type();
416 if kind.is_file() {
417 extracted = bounded_add(extracted, entry.size(), &relative)?;
418 } else if kind.is_symlink() {
419 let target = entry
420 .link_name()
421 .map_err(|error| RuntimeError::Archive(error.to_string()))?
422 .ok_or_else(|| {
423 RuntimeError::UnsafeArchive(format!(
424 "symlink {} has no target",
425 relative.display()
426 ))
427 })?;
428 validate_symlink_target(&relative, &target)?;
429 } else if !kind.is_dir() {
430 return Err(RuntimeError::UnsafeArchive(format!(
431 "{} has unsupported tar entry type",
432 relative.display()
433 )));
434 }
435 let unpacked = entry
436 .unpack_in(destination)
437 .map_err(|error| RuntimeError::Archive(error.to_string()))?;
438 if !unpacked {
439 return Err(RuntimeError::UnsafeArchive(relative.display().to_string()));
440 }
441 }
442 Ok(())
443}
444
445fn extract_zip(archive_path: &Path, destination: &Path) -> Result<(), RuntimeError> {
446 let file = File::open(archive_path)
447 .map_err(|source| io_error("open runtime archive", archive_path, source))?;
448 let mut archive =
449 zip::ZipArchive::new(file).map_err(|error| RuntimeError::Archive(error.to_string()))?;
450 let mut extracted = 0_u64;
451 for index in 0..archive.len() {
452 let mut entry = archive
453 .by_index(index)
454 .map_err(|error| RuntimeError::Archive(error.to_string()))?;
455 let relative = entry.enclosed_name().ok_or_else(|| {
456 RuntimeError::UnsafeArchive(format!("unsafe zip path {}", entry.name()))
457 })?;
458 validate_relative_path(&relative)?;
459 if entry
460 .unix_mode()
461 .is_some_and(|mode| mode & 0o170_000 == 0o120_000)
462 {
463 return Err(RuntimeError::UnsafeArchive(format!(
464 "zip symlinks are not accepted: {}",
465 relative.display()
466 )));
467 }
468 let output_path = destination.join(&relative);
469 if entry.is_dir() {
470 fs::create_dir_all(&output_path)
471 .map_err(|source| io_error("create archive directory", &output_path, source))?;
472 continue;
473 }
474 extracted = bounded_add(extracted, entry.size(), &relative)?;
475 if let Some(parent) = output_path.parent() {
476 fs::create_dir_all(parent)
477 .map_err(|source| io_error("create archive directory", parent, source))?;
478 }
479 let mut output = OpenOptions::new()
480 .create_new(true)
481 .write(true)
482 .open(&output_path)
483 .map_err(|source| io_error("create extracted file", &output_path, source))?;
484 std::io::copy(&mut entry, &mut output)
485 .map_err(|source| io_error("extract runtime file", &output_path, source))?;
486 }
487 Ok(())
488}
489
490fn bounded_add(total: u64, size: u64, path: &Path) -> Result<u64, RuntimeError> {
491 let total = total.checked_add(size).ok_or_else(|| {
492 RuntimeError::UnsafeArchive(format!("expanded size overflow at {}", path.display()))
493 })?;
494 if total > MAX_EXTRACTED_BYTES {
495 return Err(RuntimeError::UnsafeArchive(format!(
496 "expanded archive exceeds {MAX_EXTRACTED_BYTES} bytes"
497 )));
498 }
499 Ok(total)
500}
501
502fn validate_relative_path(path: &Path) -> Result<(), RuntimeError> {
503 if path.as_os_str().is_empty()
504 || path
505 .components()
506 .any(|component| !matches!(component, Component::Normal(_) | Component::CurDir))
507 {
508 return Err(RuntimeError::UnsafeArchive(path.display().to_string()));
509 }
510 Ok(())
511}
512
513fn validate_symlink_target(path: &Path, target: &Path) -> Result<(), RuntimeError> {
514 if target.is_absolute() {
515 return Err(RuntimeError::UnsafeArchive(format!(
516 "{} points outside the install root",
517 path.display()
518 )));
519 }
520 let mut depth = path.parent().map_or(0, |parent| {
521 parent
522 .components()
523 .filter(|component| matches!(component, Component::Normal(_)))
524 .count()
525 });
526 for component in target.components() {
527 match component {
528 Component::Normal(_) => depth += 1,
529 Component::CurDir => {}
530 Component::ParentDir if depth > 0 => depth -= 1,
531 _ => {
532 return Err(RuntimeError::UnsafeArchive(format!(
533 "{} has unsafe symlink target {}",
534 path.display(),
535 target.display()
536 )));
537 }
538 }
539 }
540 Ok(())
541}
542
543fn collect_entries(root: &Path) -> Result<Vec<InstalledEntry>, RuntimeError> {
544 fn walk(
545 root: &Path,
546 directory: &Path,
547 entries: &mut Vec<InstalledEntry>,
548 ) -> Result<(), RuntimeError> {
549 let children = fs::read_dir(directory)
550 .map_err(|source| io_error("inspect runtime install", directory, source))?;
551 for child in children {
552 let child =
553 child.map_err(|source| io_error("inspect runtime install", directory, source))?;
554 let path = child.path();
555 let relative = path
556 .strip_prefix(root)
557 .expect("walked path remains beneath root")
558 .to_path_buf();
559 if relative == Path::new(MANIFEST_FILE) {
560 continue;
561 }
562 let metadata = fs::symlink_metadata(&path)
563 .map_err(|source| io_error("inspect runtime entry", &path, source))?;
564 if metadata.is_dir() {
565 walk(root, &path, entries)?;
566 } else if metadata.is_file() {
567 let (sha256, size_bytes) = hash_file(&path)?;
568 entries.push(InstalledEntry::File {
569 path: relative,
570 sha256,
571 size_bytes,
572 });
573 } else if metadata.file_type().is_symlink() {
574 let target = fs::read_link(&path)
575 .map_err(|source| io_error("read runtime symlink", &path, source))?;
576 validate_symlink_target(&relative, &target)?;
577 entries.push(InstalledEntry::Symlink {
578 path: relative,
579 target,
580 });
581 } else {
582 return Err(RuntimeError::InvalidInstall(format!(
583 "unsupported installed entry {}",
584 relative.display()
585 )));
586 }
587 }
588 Ok(())
589 }
590
591 let mut entries = Vec::new();
592 walk(root, root, &mut entries)?;
593 entries.sort_by(|left, right| entry_path(left).cmp(entry_path(right)));
594 Ok(entries)
595}
596
597fn entry_path(entry: &InstalledEntry) -> &Path {
598 match entry {
599 InstalledEntry::File { path, .. } | InstalledEntry::Symlink { path, .. } => path,
600 }
601}
602
603fn write_manifest(root: &Path, manifest: &InstallManifest) -> Result<(), RuntimeError> {
604 let path = root.join(MANIFEST_FILE);
605 let bytes = serde_json::to_vec_pretty(manifest)?;
606 let mut file = OpenOptions::new()
607 .create_new(true)
608 .write(true)
609 .open(&path)
610 .map_err(|source| io_error("create runtime manifest", &path, source))?;
611 file.write_all(&bytes)
612 .and_then(|()| file.sync_all())
613 .map_err(|source| io_error("write runtime manifest", &path, source))
614}
615
616fn validate_install(root: &Path, artifact: &RuntimeArtifact) -> Result<PathBuf, RuntimeError> {
617 let root_metadata = fs::symlink_metadata(root).map_err(|error| {
618 RuntimeError::InvalidInstall(format!("cannot inspect install root: {error}"))
619 })?;
620 if !root_metadata.is_dir() || root_metadata.file_type().is_symlink() {
621 return Err(RuntimeError::InvalidInstall(format!(
622 "{} is not a directory",
623 root.display()
624 )));
625 }
626 let manifest_path = root.join(MANIFEST_FILE);
627 let bytes = fs::read(&manifest_path)
628 .map_err(|error| RuntimeError::InvalidInstall(format!("cannot read manifest: {error}")))?;
629 let manifest: InstallManifest = serde_json::from_slice(&bytes)
630 .map_err(|error| RuntimeError::InvalidInstall(format!("invalid manifest: {error}")))?;
631 if manifest.schema_version != 1
632 || manifest.release != LLAMA_CPP_RELEASE
633 || manifest.commit != LLAMA_CPP_COMMIT
634 || manifest.platform != artifact.platform
635 || manifest.backend != artifact.backend
636 || !manifest
637 .archive_sha256
638 .eq_ignore_ascii_case(&artifact.spec.sha256)
639 || manifest.server_relative_path != artifact.server_relative_path
640 {
641 return Err(RuntimeError::InvalidInstall(
642 "manifest identity does not match the pinned runtime".to_owned(),
643 ));
644 }
645 let current =
646 collect_entries(root).map_err(|error| RuntimeError::InvalidInstall(error.to_string()))?;
647 if current != manifest.entries {
648 return Err(RuntimeError::InvalidInstall(
649 "installed runtime files differ from the integrity manifest".to_owned(),
650 ));
651 }
652 let executable = root.join(&manifest.server_relative_path);
653 if !executable.is_file() {
654 return Err(RuntimeError::InvalidInstall(
655 "server executable is missing".to_owned(),
656 ));
657 }
658 Ok(executable)
659}
660
661#[cfg(unix)]
662fn make_executable(path: &Path) -> Result<(), RuntimeError> {
663 use std::os::unix::fs::PermissionsExt;
664
665 let mut permissions = fs::metadata(path)
666 .map_err(|source| io_error("inspect server executable", path, source))?
667 .permissions();
668 permissions.set_mode(permissions.mode() | 0o700);
669 fs::set_permissions(path, permissions)
670 .map_err(|source| io_error("mark server executable", path, source))
671}
672
673#[cfg(not(unix))]
674fn make_executable(_path: &Path) -> Result<(), RuntimeError> {
675 Ok(())
676}
677
678fn quarantine_directory(path: &Path) -> Result<(), RuntimeError> {
679 let file_name = path
680 .file_name()
681 .and_then(|name| name.to_str())
682 .ok_or_else(|| RuntimeError::InvalidInstall(path.display().to_string()))?;
683 let target = path.with_file_name(format!(".{file_name}.invalid-{}", Uuid::new_v4()));
684 fs::rename(path, &target).map_err(|source| io_error("quarantine invalid runtime", path, source))
685}
686
687fn io_error(operation: &'static str, path: &Path, source: std::io::Error) -> RuntimeError {
688 RuntimeError::Io {
689 operation,
690 path: path.to_path_buf(),
691 source,
692 }
693}
694
695struct DirectoryLock {
696 file: File,
697}
698
699impl DirectoryLock {
700 fn acquire(path: &Path, timeout: Duration) -> Result<Self, RuntimeError> {
701 let started = Instant::now();
702 match fs::symlink_metadata(path) {
703 Ok(metadata) if metadata.file_type().is_symlink() => {
704 return Err(RuntimeError::InvalidInstall(format!(
705 "runtime lock must not be a symlink: {}",
706 path.display()
707 )));
708 }
709 Ok(_) => {}
710 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
711 Err(source) => return Err(io_error("inspect runtime install lock", path, source)),
712 }
713 let file = OpenOptions::new()
714 .create(true)
715 .read(true)
716 .write(true)
717 .truncate(false)
718 .open(path)
719 .map_err(|source| io_error("open runtime install lock", path, source))?;
720 loop {
721 match FileExt::try_lock_exclusive(&file) {
722 Ok(()) => return Ok(Self { file }),
723 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
724 if started.elapsed() >= timeout {
725 return Err(RuntimeError::LockTimeout {
726 path: path.to_path_buf(),
727 seconds: timeout.as_secs(),
728 });
729 }
730 thread::sleep(Duration::from_millis(50));
731 }
732 Err(source) => return Err(io_error("lock runtime install", path, source)),
733 }
734 }
735 }
736}
737
738impl Drop for DirectoryLock {
739 fn drop(&mut self) {
740 let _ = FileExt::unlock(&self.file);
741 }
742}
743
744struct StagingDirectory {
745 path: PathBuf,
746}
747
748impl StagingDirectory {
749 fn create(path: PathBuf) -> Result<Self, RuntimeError> {
750 fs::create_dir(&path)
751 .map_err(|source| io_error("create runtime staging directory", &path, source))?;
752 Ok(Self { path })
753 }
754
755 fn path(&self) -> &Path {
756 &self.path
757 }
758
759 fn publish(&mut self, destination: &Path) -> Result<(), RuntimeError> {
760 fs::rename(&self.path, destination)
761 .map_err(|source| io_error("publish runtime installation", destination, source))?;
762 self.path = PathBuf::new();
763 Ok(())
764 }
765}
766
767impl Drop for StagingDirectory {
768 fn drop(&mut self) {
769 if !self.path.as_os_str().is_empty() {
770 let _ = fs::remove_dir_all(&self.path);
771 }
772 }
773}
774
775#[cfg(test)]
776mod tests {
777 use std::io::Write;
778
779 use super::{
780 Architecture, ArchiveFormat, InstallManifest, LLAMA_CPP_COMMIT, LLAMA_CPP_RELEASE,
781 OperatingSystem, RuntimeArtifact, RuntimeBackend, RuntimeError, RuntimePlatform,
782 collect_entries, extract_zip, make_executable, runtime_artifact, validate_install,
783 validate_symlink_target, write_manifest,
784 };
785 use crate::artifact::ArtifactSpec;
786
787 #[test]
788 fn catalog_is_platform_and_backend_specific() {
789 let linux = RuntimePlatform {
790 os: OperatingSystem::Linux,
791 architecture: Architecture::X86_64,
792 };
793 let cpu = runtime_artifact(linux, RuntimeBackend::Cpu).expect("cpu");
794 let rocm = runtime_artifact(linux, RuntimeBackend::Rocm).expect("rocm");
795 assert_ne!(cpu.spec.sha256, rocm.spec.sha256);
796 assert!(rocm.spec.file_name.contains("rocm-7.14"));
797
798 let unsupported = RuntimePlatform {
799 os: OperatingSystem::Macos,
800 architecture: Architecture::Aarch64,
801 };
802 assert!(matches!(
803 runtime_artifact(unsupported, RuntimeBackend::Rocm),
804 Err(RuntimeError::UnsupportedTarget { .. })
805 ));
806
807 let windows = RuntimePlatform {
808 os: OperatingSystem::Windows,
809 architecture: Architecture::X86_64,
810 };
811 assert_eq!(
812 runtime_artifact(windows, RuntimeBackend::Cpu)
813 .expect("windows cpu")
814 .server_relative_path,
815 std::path::PathBuf::from("llama-server.exe")
816 );
817 }
818
819 #[test]
820 fn symlink_validation_allows_internal_links_only() {
821 assert!(
822 validate_symlink_target(
823 std::path::Path::new("root/lib.so"),
824 std::path::Path::new("lib.so.1")
825 )
826 .is_ok()
827 );
828 assert!(
829 validate_symlink_target(
830 std::path::Path::new("root/lib.so"),
831 std::path::Path::new("../../escape")
832 )
833 .is_err()
834 );
835 assert!(
836 validate_symlink_target(
837 std::path::Path::new("root/lib.so"),
838 std::path::Path::new("/absolute")
839 )
840 .is_err()
841 );
842 }
843
844 #[test]
845 fn zip_extraction_rejects_parent_traversal() {
846 let directory = tempfile::tempdir().expect("tempdir");
847 let archive_path = directory.path().join("unsafe.zip");
848 let file = std::fs::File::create(&archive_path).expect("archive");
849 let mut archive = zip::ZipWriter::new(file);
850 archive
851 .start_file(
852 "../escape",
853 zip::write::SimpleFileOptions::default()
854 .compression_method(zip::CompressionMethod::Stored),
855 )
856 .expect("entry");
857 archive.write_all(b"escape").expect("bytes");
858 archive.finish().expect("finish");
859 let target = directory.path().join("target");
860 std::fs::create_dir(&target).expect("target");
861
862 assert!(matches!(
863 extract_zip(&archive_path, &target),
864 Err(RuntimeError::UnsafeArchive(_))
865 ));
866 assert!(!directory.path().join("escape").exists());
867 assert_eq!(ArchiveFormat::Zip, ArchiveFormat::Zip);
868 }
869
870 #[test]
871 fn install_manifest_detects_tampering_before_reuse() {
872 let directory = tempfile::tempdir().expect("tempdir");
873 let root = directory.path().join("runtime");
874 let release_root = root.join(format!("llama-{LLAMA_CPP_RELEASE}"));
875 std::fs::create_dir_all(&release_root).expect("release root");
876 let executable = release_root.join("llama-server");
877 std::fs::write(&executable, b"trusted executable").expect("executable");
878 make_executable(&executable).expect("permissions");
879 let platform = RuntimePlatform {
880 os: OperatingSystem::Linux,
881 architecture: Architecture::X86_64,
882 };
883 let artifact = RuntimeArtifact {
884 spec: ArtifactSpec {
885 id: "llama.cpp".to_owned(),
886 version: LLAMA_CPP_RELEASE.to_owned(),
887 file_name: "runtime.tar.gz".to_owned(),
888 url: "https://example.invalid/runtime.tar.gz".to_owned(),
889 sha256: "a".repeat(64),
890 size_bytes: 1,
891 },
892 platform,
893 backend: RuntimeBackend::Cpu,
894 archive_format: ArchiveFormat::TarGz,
895 server_relative_path: executable
896 .strip_prefix(&root)
897 .expect("relative")
898 .to_path_buf(),
899 };
900 let manifest = InstallManifest {
901 schema_version: 1,
902 release: LLAMA_CPP_RELEASE.to_owned(),
903 commit: LLAMA_CPP_COMMIT.to_owned(),
904 platform,
905 backend: RuntimeBackend::Cpu,
906 archive_sha256: artifact.spec.sha256.clone(),
907 server_relative_path: artifact.server_relative_path.clone(),
908 entries: collect_entries(&root).expect("entries"),
909 };
910 write_manifest(&root, &manifest).expect("manifest");
911 assert!(validate_install(&root, &artifact).is_ok());
912
913 std::fs::write(&executable, b"tampered executable").expect("tamper");
914 assert!(matches!(
915 validate_install(&root, &artifact),
916 Err(RuntimeError::InvalidInstall(_))
917 ));
918 }
919}