Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
super-stt-indexer/src/main.rs
Line
Count
Source
1
// SPDX-License-Identifier: GPL-3.0-only
2
//! `super-stt-indexer` — top-level orchestration.
3
4
use std::path::PathBuf;
5
6
use anyhow::Context;
7
use clap::Parser;
8
use log::{error, info, warn};
9
10
use super_stt_forge::{ForgeClient, RepoRef};
11
12
mod assets;
13
mod carryforward;
14
mod index_json;
15
mod license;
16
mod local;
17
mod manifest;
18
mod registry_toml;
19
mod resolve;
20
21
#[derive(Parser, Debug)]
22
#[command(version, about)]
23
struct Args {
24
    #[command(subcommand)]
25
    command: Command,
26
}
27
28
#[derive(clap::Subcommand, Debug)]
29
enum Command {
30
    /// Build the published index from `registry.toml` + GitHub releases.
31
    Build(BuildArgs),
32
    /// Build a local index from staged backends — offline, no GitHub. For
33
    /// testing the daemon's download/install pipeline against a localhost
34
    /// static server.
35
    Local(local::LocalArgs),
36
}
37
38
#[derive(clap::Args, Debug)]
39
struct BuildArgs {
40
    /// Path to `registry.toml` to read.
41
    #[arg(long, default_value = "registry/registry.toml")]
42
    registry: PathBuf,
43
    /// Path to the previously-published `index.json` (for carry-forward). If
44
    /// missing, falls through cleanly — bootstrap mode.
45
    #[arg(long)]
46
    prior_index: Option<PathBuf>,
47
    /// Where to write the new `index.json`.
48
    #[arg(long, default_value = "index.json")]
49
    out: PathBuf,
50
}
51
52
pub struct BuildFailure {
53
    pub error: String,
54
    pub attempted_version: Option<String>,
55
    pub attempted_tag: Option<String>,
56
}
57
58
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
59
12
async fn main() -> anyhow::Result<()> {
60
    // Workspace reqwest uses rustls without a bundled provider; install one.
61
12
    super_stt_forge::install_crypto_provider();
62
12
    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
63
12
    match Args::parse().command {
64
12
        Command::Build(
args2
) =>
run_build2
(args).await,
65
12
        Command::Local(
args10
) =>
local::run10
(
&args10
),
66
12
    }
67
12
}
68
69
/// Build the published index from `registry.toml` + GitHub releases.
70
2
async fn run_build(args: BuildArgs) -> anyhow::Result<()> {
71
2
    let registry_text = std::fs::read_to_string(&args.registry)
72
2
        .with_context(|| 
format!0
("reading {}",
args.registry.display()0
))
?0
;
73
2
    let registry = registry_toml::Registry::parse(&registry_text)
?0
;
74
75
2
    let prior = match args.prior_index.as_ref() {
76
0
        Some(p) if p.exists() => {
77
0
            let text = std::fs::read_to_string(p)?;
78
0
            Some(serde_json::from_str::<index_json::Index>(&text)?)
79
        }
80
2
        _ => None,
81
    };
82
83
    // Downloads release assets (subprocess bundles can be multi-GB) — use the
84
    // shared download client, not a timeout-less `Client::new()` that could hang
85
    // forever on a stalled connection.
86
2
    let http = super_stt_forge::http::download_client();
87
2
    let now_iso = chrono_now_iso();
88
89
2
    let mut out_backends: Vec<index_json::IndexBackend> = Vec::new();
90
91
2
    for (id, entry) in &registry.0 {
92
2
        if entry.removed {
93
0
            info!("skip `{id}` — removed");
94
0
            continue;
95
2
        }
96
2
        let client = super_stt_forge::client(entry.forge);
97
        // A malformed `repo` string must not abort the whole build — route it
98
        // through the same per-entry carry-forward path every other failure uses
99
        // (Tier 1 #28), instead of `?`-propagating out of the loop.
100
2
        let built = match RepoRef::parse(&entry.repo) {
101
2
            Ok(repo) => build_entry(client.as_ref(), &http, id, entry, &repo).await,
102
0
            Err(e) => Err(BuildFailure {
103
0
                error: format!("invalid repo `{}`: {e}", entry.repo),
104
0
                attempted_version: None,
105
0
                attempted_tag: None,
106
0
            }),
107
        };
108
2
        match built {
109
2
            Ok(b) => out_backends.push(b),
110
0
            Err(failure) => {
111
0
                error!("entry `{id}` failed: {}", failure.error);
112
0
                let prior_entry = prior
113
0
                    .as_ref()
114
0
                    .and_then(|p| p.backends.iter().find(|b| b.id == *id));
115
0
                if let Some(carried) = carryforward::maybe_carry_forward(
116
0
                    id,
117
0
                    prior_entry,
118
0
                    &failure.error,
119
0
                    failure.attempted_version.as_deref().unwrap_or(""),
120
0
                    failure.attempted_tag.as_deref().unwrap_or(""),
121
0
                    &now_iso,
122
0
                    carryforward::MAX_STALENESS_DAYS,
123
0
                ) {
124
0
                    warn!(
125
                        "entry `{id}` — carrying forward last-known-good (v{})",
126
                        carried.version
127
                    );
128
0
                    out_backends.push(carried);
129
0
                }
130
            }
131
        }
132
    }
133
134
2
    ensure_unique_sources(&out_backends)
?0
;
135
2
    ensure_unique_backend_ids(&out_backends)
?0
;
136
137
2
    let index = index_json::Index {
138
2
        schema_version: index_json::SCHEMA_VERSION,
139
2
        generated_at: now_iso,
140
2
        min_client: index_json::MIN_CLIENT.into(),
141
2
        backends: out_backends,
142
2
    };
143
2
    let text = serde_json::to_string_pretty(&index)
?0
+ "\n";
144
2
    super_stt_registry_types::fs::write_atomic(&args.out, text.as_bytes())
145
2
        .with_context(|| 
format!0
("writing {}",
args.out.display()0
))
?0
;
146
2
    info!(
147
        "wrote {} ({} backends)",
148
2
        args.out.display(),
149
2
        index.backends.len()
150
    );
151
2
    Ok(())
152
2
}
153
154
2
async fn build_entry(
155
2
    client: &dyn ForgeClient,
156
2
    http: &reqwest::Client,
157
2
    id: &str,
158
2
    entry: &registry_toml::Entry,
159
2
    repo: &RepoRef,
160
2
) -> Result<index_json::IndexBackend, BuildFailure> {
161
2
    let resolved = resolve::resolve(client, repo, entry)
162
2
        .await
163
2
        .map_err(|e| BuildFailure {
164
0
            error: format!("{e:#}"),
165
0
            attempted_version: None,
166
0
            attempted_tag: None,
167
0
        })?;
168
    // From here the version + tag are known; record them on every later failure
169
    // so the carry-forward path can report what it tried to build.
170
2
    let attempted_version = Some(resolved.version.to_string());
171
2
    let attempted_tag = Some(resolved.tag.clone());
172
2
    let fail = |e: &dyn std::fmt::Display| BuildFailure {
173
0
        error: format!("{e:#}"),
174
0
        attempted_version: attempted_version.clone(),
175
0
        attempted_tag: attempted_tag.clone(),
176
0
    };
177
178
    // The manifest is the `backend.toml` release asset: parse + validate the
179
    // exact bytes that get hashed, so reviewed == pinned == installed. A release
180
    // without the asset is not installable (no synthesize fallback) — fail the
181
    // entry.
182
2
    let (url, _declared_size) =
183
2
        assets::resolve_url("backend.toml", &resolved.release.assets).map_err(|e| 
fail0
(
&e0
))
?0
;
184
2
    let (bytes, sha256) = assets::fetch_manifest_asset(http, &url)
185
2
        .await
186
2
        .map_err(|e| 
fail0
(
&e0
))
?0
;
187
2
    let size = bytes.len() as u64;
188
2
    let text = String::from_utf8(bytes).map_err(|e| 
fail0
(
&e0
))
?0
;
189
2
    let m = manifest::Manifest::parse(&text).map_err(|e| 
fail0
(
&e0
))
?0
;
190
2
    let manifest_pin = Some(index_json::IndexAsset { url, size, sha256 });
191
2
    manifest::validate(&m, &resolved.version, &entry.repo, entry.id.as_deref())
192
2
        .map_err(|e| 
fail0
(
&e0
))
?0
;
193
2
    let idx_assets = resolve_index_assets(http, &m, &resolved.release.assets)
194
2
        .await
195
2
        .map_err(|e| 
fail0
(
&e0
))
?0
;
196
197
2
    Ok(into_index_backend(
198
2
        id,
199
2
        m,
200
2
        resolved.version.to_string(),
201
2
        resolved.tag,
202
2
        idx_assets,
203
2
        manifest_pin,
204
2
    ))
205
2
}
206
207
/// A unique temp path for staging one downloaded asset part during validation.
208
0
fn temp_part_path() -> std::path::PathBuf {
209
    use std::sync::atomic::{AtomicU64, Ordering};
210
    static N: AtomicU64 = AtomicU64::new(0);
211
0
    let n = N.fetch_add(1, Ordering::Relaxed);
212
0
    std::env::temp_dir().join(format!("stt-idx-{}-{n}.part", std::process::id()))
213
0
}
214
215
/// RAII owner of the downloaded part files: removes them all on drop, so a
216
/// mid-loop download/validation error can't leak the (possibly multi-GB) parts
217
/// already fetched (Tier 1 #29). A path is registered *before* its download so
218
/// even a partially-written part is cleaned up.
219
struct TempParts(Vec<std::path::PathBuf>);
220
221
impl TempParts {
222
0
    fn new() -> Self {
223
0
        Self(Vec::new())
224
0
    }
225
0
    fn register(&mut self, path: std::path::PathBuf) {
226
0
        self.0.push(path);
227
0
    }
228
0
    fn paths(&self) -> &[std::path::PathBuf] {
229
0
        &self.0
230
0
    }
231
}
232
233
impl Drop for TempParts {
234
0
    fn drop(&mut self) {
235
0
        for p in &self.0 {
236
0
            let _ = std::fs::remove_file(p);
237
0
        }
238
0
    }
239
}
240
241
/// Build the index entry for one subprocess variant from its downloaded part
242
/// pins: a single-file pin (`url`/`size`/`sha256`) or a multi-part pin.
243
0
fn subprocess_index_entry(
244
0
    sa: &manifest::SubprocessAsset,
245
0
    mut pins: Vec<index_json::IndexAsset>,
246
0
) -> index_json::IndexSubprocessAsset {
247
0
    let (url, size, sha256, parts) = if sa.is_multipart() {
248
0
        (None, None, None, pins)
249
    } else {
250
0
        let p = pins.remove(0);
251
0
        (Some(p.url), Some(p.size), Some(p.sha256), Vec::new())
252
    };
253
    index_json::IndexSubprocessAsset {
254
0
        target: sa.target.clone(),
255
0
        accel: sa.accel.iter().map(ToString::to_string).collect(),
256
0
        cuda_major: sa.cuda_major,
257
0
        cuda_sm: sa.cuda_sm,
258
0
        cudnn: sa.cudnn,
259
0
        gfx: sa.gfx.iter().map(ToString::to_string).collect(),
260
0
        vulkan_api: sa.vulkan_api.map(|v| v.to_string()),
261
0
        url,
262
0
        size,
263
0
        sha256,
264
0
        parts,
265
    }
266
0
}
267
268
/// Resolve and hash the binary artifacts a release declares — the wasm
269
/// component or each subprocess variant — into the index's asset block.
270
2
async fn resolve_index_assets(
271
2
    http: &reqwest::Client,
272
2
    m: &manifest::Manifest,
273
2
    release_assets: &[super_stt_forge::ReleaseAsset],
274
2
) -> anyhow::Result<index_json::IndexAssets> {
275
2
    let mut idx_assets = index_json::IndexAssets::default();
276
2
    if let Some(wasm) = &m.assets.wasm {
277
2
        let (url, size) = assets::resolve_url(wasm, release_assets)
?0
;
278
2
        let sha = assets::fetch_wasm_and_hash(http, &url, wasm).await
?0
;
279
2
        idx_assets.wasm = Some(index_json::IndexAsset {
280
2
            url,
281
2
            size,
282
2
            sha256: sha,
283
2
        });
284
0
    }
285
2
    for 
sa0
in &m.assets.subprocess {
286
        // Download each part (a single-file variant has one) to a temp file,
287
        // hashing it, then validate the reassembled archive before pinning.
288
        // `TempParts` removes every downloaded part on scope exit — including on
289
        // an early `?` from `resolve_url`/`download_to_file`/validation — so a
290
        // mid-loop error can't leak multi-GB temp files (Tier 1 #29).
291
0
        let files = sa.release_files();
292
0
        let mut tmp = TempParts::new();
293
0
        let mut pins: Vec<index_json::IndexAsset> = Vec::with_capacity(files.len());
294
0
        for f in &files {
295
0
            let (url, _) = assets::resolve_url(f, release_assets)?;
296
0
            let dest = temp_part_path();
297
0
            tmp.register(dest.clone());
298
0
            let (size, sha256) = assets::download_to_file(http, &url, f, &dest).await?;
299
0
            pins.push(index_json::IndexAsset { url, size, sha256 });
300
        }
301
0
        assets::validate_subprocess_parts(tmp.paths(), &sa.label(), &m.backend.entrypoint)?;
302
0
        idx_assets.subprocess.push(subprocess_index_entry(sa, pins));
303
        // `tmp` drops here (validation done), removing the parts.
304
    }
305
2
    Ok(idx_assets)
306
2
}
307
308
/// Assemble the published `IndexBackend` from a validated manifest, its
309
/// resolved `version` + `tag`, and the hashed assets. Thin wrapper over the
310
/// canonical [`index_json::IndexBackend::from_manifest`] synthesis (shared with
311
/// the daemon's Custom-repo and local-dir install paths) — the indexer supplies
312
/// the maintainer-declared `id` rather than deriving it from `source`.
313
14
pub(crate) fn into_index_backend(
314
14
    id: &str,
315
14
    m: manifest::Manifest,
316
14
    version: String,
317
14
    tag: String,
318
14
    assets: index_json::IndexAssets,
319
14
    manifest: Option<index_json::IndexAsset>,
320
14
) -> index_json::IndexBackend {
321
14
    index_json::IndexBackend::from_manifest(id.to_string(), m, version, tag, assets, manifest)
322
14
}
323
324
/// A backend's `source` is its unique identity. Two distinct entries that
325
/// collide on `source` would be indistinguishable to every daemon — one of
326
/// the two install directories is picked as the winner and the other is
327
/// removed from disk — so a collision must never be published: fail the build
328
/// instead.
329
6
fn ensure_unique_sources(backends: &[index_json::IndexBackend]) -> anyhow::Result<()> {
330
6
    let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
331
10
    for b in 
backends6
{
332
10
        if let Some(
prev_id2
) = seen.insert(b.source.as_str(), b.id.as_str()) {
333
2
            anyhow::bail!(
334
                "duplicate source `{}` shared by entries `{}` and `{}`; each backend must have a distinct source",
335
                b.source,
336
                prev_id,
337
                b.id,
338
            );
339
8
        }
340
    }
341
4
    Ok(())
342
6
}
343
344
/// A backend's `backend_id` names the directory it is installed into, so two
345
/// entries publishing the same one would install over each other — the second
346
/// install replaces the first, taking its downloaded model files with it.
347
///
348
/// Per-entry validation cannot see this: each release's manifest declares its
349
/// own `id` in isolation, and both are individually well-formed. Like
350
/// [`ensure_unique_sources`], the collision is only visible across the
351
/// assembled index, so it is checked here and fails the build.
352
///
353
/// An entry without a `backend_id` installs under its registry key, which
354
/// [`ensure_unique_sources`]' own key space already keeps distinct, so those
355
/// entries are skipped rather than grouped together under a shared absence.
356
8
fn ensure_unique_backend_ids(backends: &[index_json::IndexBackend]) -> anyhow::Result<()> {
357
8
    let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
358
14
    for b in 
backends8
{
359
14
        let Some(
backend_id10
) = b.backend_id.as_deref() else {
360
4
            continue;
361
        };
362
10
        if let Some(
prev_id2
) = seen.insert(backend_id, b.id.as_str()) {
363
2
            anyhow::bail!(
364
                "duplicate backend id `{}` shared by entries `{}` and `{}`; each backend must declare a distinct [backend].id",
365
                backend_id,
366
                prev_id,
367
                b.id,
368
            );
369
8
        }
370
    }
371
6
    Ok(())
372
8
}
373
374
12
fn chrono_now_iso() -> String {
375
12
    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
376
12
}
377
378
#[cfg(test)]
379
mod tests {
380
    use super::*;
381
382
20
    fn backend(id: &str, source: &str) -> index_json::IndexBackend {
383
20
        index_json::IndexBackend {
384
20
            id: id.into(),
385
20
            backend_id: None,
386
20
            source: source.into(),
387
20
            version: "1.0.0".into(),
388
20
            tag: "v1.0.0".into(),
389
20
            name: id.into(),
390
20
            description: None,
391
20
            license: "Apache-2.0".into(),
392
20
            kind: "wasm".into(),
393
20
            contract: "v1".into(),
394
20
            min_client: None,
395
20
            entrypoint: format!("{id}.wasm"),
396
20
            allowed_hosts: Vec::new(),
397
20
            online: false,
398
20
            supports_gpu: false,
399
20
            supports_cpu: true,
400
20
            models: Vec::new(),
401
20
            secrets: Vec::new(),
402
20
            options: Vec::new(),
403
20
            assets: index_json::IndexAssets::default(),
404
20
            index_stale: None,
405
20
            manifest: None,
406
20
        }
407
20
    }
408
409
    #[test]
410
2
    fn unique_sources_pass() {
411
2
        let backends = vec![
412
2
            backend("mistral", "github.com/x/y/mistral"),
413
2
            backend("openai", "github.com/x/y/openai"),
414
        ];
415
2
        ensure_unique_sources(&backends).unwrap();
416
2
    }
417
418
    #[test]
419
2
    fn duplicate_sources_are_rejected() {
420
2
        let backends = vec![
421
2
            backend("mistral", "github.com/x/y"),
422
2
            backend("openai", "github.com/x/y"),
423
        ];
424
2
        let err = ensure_unique_sources(&backends).unwrap_err();
425
2
        assert!(err.to_string().contains("duplicate source"));
426
2
    }
427
428
    /// `backend_id` names the install directory, so publishing two entries
429
    /// that share one would have the second install replace the first —
430
    /// including the model files under it. Each entry's own manifest is
431
    /// perfectly valid, so only this cross-entry check can catch it.
432
    #[test]
433
2
    fn duplicate_backend_ids_are_rejected() {
434
2
        let mut a = backend("mistral", "github.com/x/mistral");
435
2
        a.backend_id = Some("app.super-stt.voxtral".into());
436
2
        let mut b = backend("voxtral", "github.com/x/voxtral");
437
2
        b.backend_id = Some("app.super-stt.voxtral".into());
438
439
2
        let err = ensure_unique_backend_ids(&[a, b]).unwrap_err();
440
2
        let msg = err.to_string();
441
2
        assert!(msg.contains("duplicate backend id"), "{msg}");
442
2
        assert!(msg.contains("app.super-stt.voxtral"), "{msg}");
443
2
        assert!(msg.contains("mistral") && msg.contains("voxtral"), "{msg}");
444
2
    }
445
446
    #[test]
447
2
    fn distinct_backend_ids_pass() {
448
2
        let mut a = backend("mistral", "github.com/x/mistral");
449
2
        a.backend_id = Some("app.super-stt.mistral".into());
450
2
        let mut b = backend("voxtral", "github.com/x/voxtral");
451
2
        b.backend_id = Some("app.super-stt.voxtral".into());
452
453
2
        ensure_unique_backend_ids(&[a, b]).unwrap();
454
2
    }
455
456
    /// Entries that predate `[backend].id` install under their registry key,
457
    /// which is already unique. Several of them sharing a `None` must not be
458
    /// mistaken for a collision.
459
    #[test]
460
2
    fn entries_without_a_backend_id_never_collide() {
461
2
        let backends = vec![
462
2
            backend("mistral", "github.com/x/mistral"),
463
2
            backend("voxtral", "github.com/x/voxtral"),
464
        ];
465
4
        
assert!2
(
backends.iter()2
.
all2
(|b| b.backend_id.is_none()));
466
2
        ensure_unique_backend_ids(&backends).unwrap();
467
2
    }
468
}