use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use super::bundle_manifest;
const BUNDLE_REPO: &str = "noahsabaj/mermaid-searxng";
pub async fn ensure_bundle() -> Result<PathBuf> {
let runtime = runtime_dir()?;
if version_is_current(&marker_contents(&runtime)) {
return Ok(runtime);
}
let target = target_triple().ok_or_else(unsupported_platform)?;
let expected_sha = bundle_manifest::bundle_sha256(target).ok_or_else(unsupported_platform)?;
provision(target, expected_sha, &runtime).await?;
Ok(runtime)
}
fn unsupported_platform() -> anyhow::Error {
anyhow!(
"no sovereign SearXNG bundle is available for this platform ({}/{}). \
Set OLLAMA_API_KEY, or point `[web] searxng_url` at your own instance.",
std::env::consts::OS,
std::env::consts::ARCH
)
}
fn runtime_dir() -> Result<PathBuf> {
Ok(crate::runtime::data_dir()?.join("searxng").join("runtime"))
}
fn marker_contents(runtime: &Path) -> String {
std::fs::read_to_string(runtime.join(".version")).unwrap_or_default()
}
fn version_is_current(marker: &str) -> bool {
marker.trim() == bundle_manifest::BUNDLE_VERSION
}
fn target_triple() -> Option<&'static str> {
triple_for(std::env::consts::OS, std::env::consts::ARCH)
}
fn triple_for(os: &str, arch: &str) -> Option<&'static str> {
Some(match (os, arch) {
("linux", "x86_64") => "linux-x86_64",
("linux", "aarch64") => "linux-aarch64",
("macos", "aarch64") => "macos-aarch64",
("macos", "x86_64") => "macos-x86_64",
_ => return None,
})
}
fn asset_name(target: &str) -> String {
format!("mermaid-searxng-{target}.tar.zst")
}
async fn provision(target: &str, expected_sha: &str, runtime: &Path) -> Result<()> {
let asset = asset_name(target);
tracing::info!("fetching the local search engine ({asset}) — first run only");
let url = format!(
"https://github.com/{BUNDLE_REPO}/releases/download/{}/{asset}",
bundle_manifest::BUNDLE_VERSION
);
let staging = crate::utils::private_temp_dir()
.context("creating the private staging dir for the SearXNG bundle")?
.join("searxng-download");
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging)?;
let archive = staging.join(&asset);
let got = download_hashing(&url, &archive).await?;
if !got.eq_ignore_ascii_case(expected_sha) {
return Err(anyhow!(
"checksum mismatch for {asset}\n expected: {expected_sha}\n actual: {got}"
));
}
let incoming = runtime.with_file_name(".runtime-incoming");
let _ = std::fs::remove_dir_all(&incoming);
std::fs::create_dir_all(&incoming)?;
extract(&archive, &incoming).with_context(|| format!("unpacking {asset}"))?;
std::fs::write(incoming.join(".version"), bundle_manifest::BUNDLE_VERSION)?;
swap_into_place(&incoming, runtime)?;
let _ = std::fs::remove_dir_all(&staging);
Ok(())
}
async fn download_hashing(url: &str, dest: &Path) -> Result<String> {
use futures::StreamExt;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
let resp = reqwest::Client::new()
.get(url)
.send()
.await
.with_context(|| format!("requesting {url}"))?
.error_for_status()
.with_context(|| format!("downloading {url}"))?;
let mut file = tokio::fs::File::create(dest)
.await
.with_context(|| format!("creating {}", dest.display()))?;
let mut hasher = Sha256::new();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("reading the bundle stream")?;
hasher.update(&chunk);
file.write_all(&chunk).await?;
}
file.sync_all().await?;
Ok(hex_lower(&hasher.finalize()))
}
fn swap_into_place(from: &Path, to: &Path) -> Result<()> {
if let Some(parent) = to.parent() {
std::fs::create_dir_all(parent)?;
}
if to.exists() {
std::fs::remove_dir_all(to)
.with_context(|| format!("removing the previous bundle at {}", to.display()))?;
}
std::fs::rename(from, to)
.with_context(|| format!("moving the unpacked bundle into {}", to.display()))
}
#[cfg(unix)]
fn extract(archive: &Path, dest: &Path) -> Result<()> {
let file = std::fs::File::open(archive)?;
let decoder = ruzstd::decoding::StreamingDecoder::new(file)
.map_err(|e| anyhow!("initializing the zstd decoder: {e}"))?;
tar::Archive::new(decoder)
.unpack(dest)
.context("unpacking the .tar.zst bundle")
}
#[cfg(windows)]
fn extract(_archive: &Path, _dest: &Path) -> Result<()> {
Err(anyhow!("SearXNG bundles are not published for Windows"))
}
fn hex_lower(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn triple_covers_the_published_targets() {
assert_eq!(triple_for("linux", "x86_64"), Some("linux-x86_64"));
assert_eq!(triple_for("linux", "aarch64"), Some("linux-aarch64"));
assert_eq!(triple_for("macos", "aarch64"), Some("macos-aarch64"));
assert_eq!(triple_for("macos", "x86_64"), Some("macos-x86_64"));
}
#[test]
fn triple_is_none_for_unpublished_platforms() {
assert_eq!(triple_for("windows", "x86_64"), None); assert_eq!(triple_for("freebsd", "x86_64"), None);
}
#[test]
fn every_published_triple_has_a_pinned_checksum() {
for t in [
"linux-x86_64",
"linux-aarch64",
"macos-aarch64",
"macos-x86_64",
] {
let sha = bundle_manifest::bundle_sha256(t).unwrap_or_else(|| panic!("no sha for {t}"));
assert_eq!(sha.len(), 64, "{t} sha is not 64 hex chars");
assert!(
sha.chars().all(|c| c.is_ascii_hexdigit()),
"{t} sha not hex"
);
}
}
#[test]
fn the_current_host_target_is_pinned_when_supported() {
if let Some(t) = target_triple() {
assert!(
bundle_manifest::bundle_sha256(t).is_some(),
"published target {t} has no pinned checksum"
);
}
}
#[test]
fn asset_name_is_tar_zst() {
assert_eq!(
asset_name("linux-x86_64"),
"mermaid-searxng-linux-x86_64.tar.zst"
);
}
#[test]
fn version_marker_compare_trims_whitespace() {
assert!(version_is_current(bundle_manifest::BUNDLE_VERSION));
assert!(version_is_current(&format!(
" {}\n",
bundle_manifest::BUNDLE_VERSION
)));
assert!(!version_is_current("v0.0.1"));
assert!(!version_is_current(""));
}
#[test]
fn hex_lower_is_zero_padded_lowercase() {
assert_eq!(hex_lower(&[0x00, 0x0f, 0xa0, 0xff]), "000fa0ff");
}
}