Skip to main content

cherenkov/
storage.rs

1//! XDG locations and the durable model layout. Resolving paths never creates files.
2
3use crate::units::BYTES_PER_GB;
4use anyhow::{Context, Result, ensure};
5use serde::Serialize;
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9/// HF repository used when no model is selected.
10pub const DEFAULT_REPO: &str = "Sawfwair/Qwen3.8-Flash-Next-MLX-4bit";
11/// Immutable revision of the tested default checkpoint.
12pub const DEFAULT_REVISION: &str = "6cc9bbc0fae9ce26b7670b3ed1e26d557c154506";
13
14/// Source selector shared by preparation and serving when no model is specified.
15pub fn default_model_reference() -> String {
16    format!("hf://{DEFAULT_REPO}@{DEFAULT_REVISION}")
17}
18
19/// Resolved application locations, using XDG directories on macOS and Linux.
20#[derive(Debug, Clone, Serialize)]
21pub struct Paths {
22    /// Downloaded checkpoints and generated stores; never automatically evicted.
23    pub data: PathBuf,
24    /// Disposable transfer scratch. Inference checkpoints remain in RAM.
25    pub scratch: PathBuf,
26    /// Server configuration file.
27    pub config: PathBuf,
28}
29
30impl Paths {
31    /// Resolve locations from an explicit root or absolute XDG overrides.
32    /// An explicit root holds data, `scratch/`, and `cherenkov.toml` together.
33    /// Without overrides, use the user's `.local/share`, `.cache`, and `.config`
34    /// directories. This does not create directories or read configuration files.
35    pub fn new(root: Option<&Path>) -> Result<Self> {
36        if let Some(root) = root {
37            let root = crate::config::absolute(root)?;
38
39            return Ok(Self {
40                scratch: root.join("scratch"),
41                config: root.join("cherenkov.toml"),
42                data: root,
43            });
44        }
45
46        // Use the same dot-directory layout on macOS and Linux.
47        let home = dirs::home_dir();
48        let base =
49            |variable, fallback| xdg_dir(std::env::var_os(variable), home.as_deref(), fallback);
50
51        Ok(Self {
52            data: base("XDG_DATA_HOME", ".local/share")?.join("cherenkov"),
53            scratch: base("XDG_CACHE_HOME", ".cache")?.join("cherenkov"),
54            config: base("XDG_CONFIG_HOME", ".config")?.join("cherenkov/cherenkov.toml"),
55        })
56    }
57
58    /// HF download cache beneath the data directory.
59    pub fn downloads(&self) -> PathBuf {
60        self.data.join("downloads")
61    }
62
63    /// Resolve a model directory from `owner/name` and a full commit hash.
64    /// Reject unsafe path components and revisions that are not 40 hexadecimal digits.
65    pub fn model(&self, repo: &str, commit: &str) -> Result<PathBuf> {
66        validate_identity(repo, commit)?;
67
68        Ok(self.data.join("models").join(repo).join(commit))
69    }
70
71    /// Resolve the built-in checkpoint's directory without checking availability.
72    pub fn default_model(&self) -> PathBuf {
73        self.model(DEFAULT_REPO, DEFAULT_REVISION)
74            .expect("built-in model identity")
75    }
76
77    /// The Hub's documented snapshot layout, also used to count already cached bytes.
78    pub(crate) fn snapshot(&self, repo: &str, commit: &str) -> Result<PathBuf> {
79        validate_identity(repo, commit)?;
80
81        Ok(self
82            .downloads()
83            .join(format!("models--{}", repo.replace('/', "--")))
84            .join("snapshots")
85            .join(commit))
86    }
87}
88
89fn xdg_dir(value: Option<OsString>, home: Option<&Path>, fallback: &str) -> Result<PathBuf> {
90    // XDG overrides must be absolute; empty and relative values use the default.
91    if let Some(path) = value.map(PathBuf::from).filter(|path| path.is_absolute()) {
92        return Ok(path);
93    }
94
95    let home =
96        home.context("home directory unavailable; supply --root or absolute XDG directories")?;
97
98    Ok(home.join(fallback))
99}
100
101fn validate_identity(repo: &str, commit: &str) -> Result<()> {
102    let parts: Vec<_> = repo.split('/').collect();
103
104    ensure!(
105        parts.len() == 2 && parts.iter().all(|p| safe_component(p)),
106        "model ID must be owner/name"
107    );
108    ensure!(
109        commit.len() == 40 && commit.bytes().all(|b| b.is_ascii_hexdigit()),
110        "model revision must resolve to a full commit hash"
111    );
112
113    Ok(())
114}
115
116pub(crate) fn safe_component(value: &str) -> bool {
117    !value.is_empty()
118        && value != "."
119        && value != ".."
120        && value
121            .bytes()
122            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
123}
124
125pub(crate) fn create_private_dir(path: &Path) -> Result<()> {
126    use std::os::unix::fs::DirBuilderExt;
127
128    std::fs::DirBuilder::new()
129        .recursive(true)
130        .mode(0o700)
131        .create(path)
132        .with_context(|| format!("creating {}", path.display()))
133}
134
135/// Refuse before large writes; reserve space for filesystem and transfer overhead.
136pub(crate) fn require_space(path: &Path, additional_bytes: u64) -> Result<()> {
137    let path_c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
138    let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
139
140    if unsafe { libc::statfs(path_c.as_ptr(), &mut stat) } != 0 {
141        return Err(std::io::Error::last_os_error()).context("checking available disk space");
142    }
143
144    let free = stat.f_bavail as u64 * stat.f_bsize as u64;
145    let required = additional_bytes
146        .checked_add(2_000_000_000)
147        .context("disk budget overflow")?;
148
149    ensure!(
150        free >= required,
151        "{} needs {:.1} GB more disk space plus a 2 GB reserve; only {:.1} GB is available",
152        path.display(),
153        additional_bytes as f64 / BYTES_PER_GB as f64,
154        free as f64 / BYTES_PER_GB as f64
155    );
156
157    Ok(())
158}
159
160#[cfg(test)]
161pub(crate) fn test_model_dir() -> Option<PathBuf> {
162    let path = std::env::var_os("CHERENKOV_MODEL_DIR")
163        .map(PathBuf::from)
164        .or_else(|| Paths::new(None).ok().map(|p| p.default_model()))?;
165
166    path.join("packed/manifest.json").exists().then_some(path)
167}
168
169#[cfg(test)]
170#[path = "../tests/unit/storage.rs"]
171mod tests;