use std::path::{Path, PathBuf};
use std::process::Command;
use super::binary::{
pick_piper_release_asset, Platform, ReleaseAsset,
};
use super::PiperUnavailable;
pub(crate) const PIPER_RELEASES_LATEST_URL: &str =
"https://api.github.com/repos/rhasspy/piper/releases/latest";
pub(crate) const USER_AGENT: &str =
concat!("inkhaven/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Release {
pub tag_name: String,
pub assets: Vec<ReleaseAsset>,
}
pub(crate) fn download_piper_binary(
platform: &Platform,
cache_root: &Path,
fetch_json: impl Fn(&str) -> Result<Vec<u8>, PiperUnavailable>,
fetch_bytes: impl Fn(&str, &Path) -> Result<(), PiperUnavailable>,
) -> Result<PathBuf, PiperUnavailable> {
let json = fetch_json(PIPER_RELEASES_LATEST_URL)?;
let release = parse_release_json(&json)?;
let asset =
pick_piper_release_asset(&release.assets, platform).ok_or_else(
|| PiperUnavailable::AssetNotFound {
tag: release.tag_name.clone(),
platform: platform.label(),
},
)?;
let staging = cache_root.join(format!(
".staging-{}",
platform.cache_subdir(),
));
std::fs::create_dir_all(&staging).map_err(|e| {
PiperUnavailable::DownloadFailed(format!(
"mkdir staging {}: {e}",
staging.display(),
))
})?;
let archive_path = staging.join(&asset.name);
fetch_bytes(&asset.download_url, &archive_path)?;
let extract_dir = staging.join("extract");
let _ = std::fs::remove_dir_all(&extract_dir);
std::fs::create_dir_all(&extract_dir).map_err(|e| {
PiperUnavailable::ExtractFailed(format!(
"mkdir extract {}: {e}",
extract_dir.display(),
))
})?;
extract_archive(&archive_path, &extract_dir)?;
let target_dir = cache_root.join(platform.cache_subdir());
let extracted = locate_extracted_binary(
&extract_dir,
platform.binary_filename(),
)?;
let source_tree = extracted
.parent()
.ok_or_else(|| {
PiperUnavailable::ExtractFailed(format!(
"extracted binary `{}` has no parent directory",
extracted.display(),
))
})?
.to_path_buf();
install_tree(&source_tree, &target_dir)?;
let target = target_dir.join(platform.binary_filename());
let _ = std::fs::remove_dir_all(&staging);
Ok(target)
}
pub(crate) fn parse_release_json(bytes: &[u8]) -> Result<Release, PiperUnavailable> {
let value: serde_json::Value =
serde_json::from_slice(bytes).map_err(|e| {
PiperUnavailable::DownloadFailed(format!(
"parse release JSON: {e}",
))
})?;
let tag_name = value
.get("tag_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let assets_arr = match value.get("assets").and_then(|v| v.as_array()) {
Some(a) => a,
None => {
return Err(PiperUnavailable::DownloadFailed(
"release JSON has no `assets` array".to_string(),
));
}
};
let assets: Vec<ReleaseAsset> = assets_arr
.iter()
.filter_map(|a| {
let name = a.get("name")?.as_str()?.to_string();
let download_url =
a.get("browser_download_url")?.as_str()?.to_string();
let size = a.get("size").and_then(|v| v.as_u64()).unwrap_or(0);
Some(ReleaseAsset {
name,
download_url,
size,
})
})
.collect();
Ok(Release { tag_name, assets })
}
#[allow(dead_code)]
pub(crate) fn curl_get_json(url: &str) -> Result<Vec<u8>, PiperUnavailable> {
let output = Command::new("curl")
.args([
"-sSL",
"-A",
USER_AGENT,
"-H",
"Accept: application/vnd.github+json",
"--fail",
"--max-time",
"30",
url,
])
.output()
.map_err(|e| {
PiperUnavailable::DownloadFailed(format!("spawn curl: {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PiperUnavailable::DownloadFailed(format!(
"curl exit {:?}: {}",
output.status.code(),
stderr.trim(),
)));
}
Ok(output.stdout)
}
#[allow(dead_code)]
pub(crate) fn curl_get_to_file(url: &str, dest: &Path) -> Result<(), PiperUnavailable> {
let output = Command::new("curl")
.args([
"-sSL",
"-A",
USER_AGENT,
"--fail",
"--max-time",
"600",
"-o",
])
.arg(dest)
.arg(url)
.output()
.map_err(|e| {
PiperUnavailable::DownloadFailed(format!("spawn curl: {e}"))
})?;
if !output.status.success() {
let _ = std::fs::remove_file(dest);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PiperUnavailable::DownloadFailed(format!(
"curl exit {:?}: {}",
output.status.code(),
stderr.trim(),
)));
}
Ok(())
}
pub(crate) fn extract_archive(archive: &Path, dst: &Path) -> Result<(), PiperUnavailable> {
let archive_name = archive
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
let is_zip = archive_name.ends_with(".zip");
let args: Vec<&str> = if is_zip {
vec!["-xf"]
} else {
vec!["-xzf"]
};
let mut cmd = Command::new("tar");
cmd.args(&args).arg(archive).arg("-C").arg(dst);
let output = cmd.output().map_err(|e| {
PiperUnavailable::ExtractFailed(format!("spawn tar: {e}"))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PiperUnavailable::ExtractFailed(format!(
"tar exit {:?}: {}",
output.status.code(),
stderr.trim(),
)));
}
Ok(())
}
pub(crate) fn locate_extracted_binary(
root: &Path,
binary_name: &str,
) -> Result<PathBuf, PiperUnavailable> {
fn walk(dir: &Path, target: &str, depth: usize) -> Option<PathBuf> {
if depth > 3 {
return None;
}
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
let matches_name =
path.file_name().map(|n| n == target).unwrap_or(false);
if matches_name && path.is_file() {
return Some(path);
}
if path.is_dir() {
if let Some(found) = walk(&path, target, depth + 1) {
return Some(found);
}
}
}
None
}
walk(root, binary_name, 0).ok_or_else(|| {
PiperUnavailable::ExtractFailed(format!(
"binary `{binary_name}` not found in archive under {}",
root.display(),
))
})
}
pub(crate) fn install_tree(
src: &Path,
dst: &Path,
) -> Result<(), PiperUnavailable> {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
PiperUnavailable::ExtractFailed(format!(
"mkdir {}: {e}",
parent.display(),
))
})?;
}
let _ = std::fs::remove_dir_all(dst);
std::fs::create_dir_all(dst).map_err(|e| {
PiperUnavailable::ExtractFailed(format!(
"mkdir target {}: {e}",
dst.display(),
))
})?;
copy_dir_recursive(src, dst).map_err(|e| {
PiperUnavailable::ExtractFailed(format!(
"copy {} → {}: {e}",
src.display(),
dst.display(),
))
})?;
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE_RELEASE_JSON: &[u8] = br#"
{
"tag_name": "2023.11.14-2",
"name": "Piper 2023.11.14-2",
"draft": false,
"prerelease": false,
"assets": [
{
"name": "piper_amd64.tar.gz",
"browser_download_url": "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_amd64.tar.gz",
"size": 8388608
},
{
"name": "piper_arm64.tar.gz",
"browser_download_url": "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_arm64.tar.gz",
"size": 8388608
},
{
"name": "piper_macos_aarch64.tar.gz",
"browser_download_url": "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_macos_aarch64.tar.gz",
"size": 8388608
},
{
"name": "piper_windows_amd64.zip",
"browser_download_url": "https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_windows_amd64.zip",
"size": 8388608
}
]
}
"#;
#[test]
fn parse_release_extracts_tag_and_assets() {
let release = parse_release_json(FIXTURE_RELEASE_JSON).unwrap();
assert_eq!(release.tag_name, "2023.11.14-2");
assert_eq!(release.assets.len(), 4);
assert_eq!(release.assets[0].name, "piper_amd64.tar.gz");
assert!(release.assets[0]
.download_url
.starts_with("https://github.com/rhasspy/piper"));
assert_eq!(release.assets[0].size, 8_388_608);
}
#[test]
fn parse_release_rejects_bad_json() {
let err = parse_release_json(b"not json").unwrap_err();
assert!(matches!(err, PiperUnavailable::DownloadFailed(_)));
}
#[test]
fn parse_release_rejects_missing_assets() {
let err = parse_release_json(b"{\"tag_name\":\"x\"}").unwrap_err();
assert!(matches!(err, PiperUnavailable::DownloadFailed(_)));
assert!(err.to_user_message().contains("assets"));
}
#[test]
fn parse_release_skips_malformed_asset_entries() {
let json = br#"{
"tag_name": "x",
"assets": [
{ "browser_download_url": "https://example.test/a", "size": 1 },
{ "name": "good.tar.gz", "browser_download_url": "https://example.test/g", "size": 2 }
]
}"#;
let release = parse_release_json(json).unwrap();
assert_eq!(release.assets.len(), 1);
assert_eq!(release.assets[0].name, "good.tar.gz");
}
fn make_tarball(dir: &Path, name: &str) -> PathBuf {
let staging = dir.join("mk");
let inner = staging.join("piper");
std::fs::create_dir_all(&inner).unwrap();
std::fs::write(inner.join(name), b"fake-binary").unwrap();
let archive = dir.join(format!("{}.tar.gz", name));
let status = Command::new("tar")
.args(["-czf"])
.arg(&archive)
.args(["-C"])
.arg(&staging)
.arg("piper")
.status()
.expect("tar -czf must succeed in tests");
assert!(status.success(), "fixture tarball creation failed");
archive
}
#[test]
fn extract_archive_unpacks_real_tarball() {
let tmp = tempfile::tempdir().unwrap();
let archive = make_tarball(tmp.path(), "piper");
let dst = tmp.path().join("out");
std::fs::create_dir_all(&dst).unwrap();
extract_archive(&archive, &dst).unwrap();
assert!(dst.join("piper").join("piper").exists());
}
#[test]
fn extract_archive_errors_on_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let dst = tmp.path().join("out");
std::fs::create_dir_all(&dst).unwrap();
let err = extract_archive(
&tmp.path().join("does-not-exist.tar.gz"),
&dst,
)
.unwrap_err();
assert!(matches!(err, PiperUnavailable::ExtractFailed(_)));
}
#[test]
fn locate_binary_finds_nested() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("piper").join("piper");
std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
std::fs::write(&nested, b"x").unwrap();
let got = locate_extracted_binary(tmp.path(), "piper").unwrap();
assert_eq!(got, nested);
}
#[test]
fn locate_binary_finds_at_root() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("piper");
std::fs::write(&bin, b"x").unwrap();
let got = locate_extracted_binary(tmp.path(), "piper").unwrap();
assert_eq!(got, bin);
}
#[test]
fn locate_binary_errors_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let err = locate_extracted_binary(tmp.path(), "piper").unwrap_err();
assert!(matches!(err, PiperUnavailable::ExtractFailed(_)));
assert!(err.to_user_message().contains("piper"));
}
#[test]
fn install_tree_copies_every_file() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src-piper");
std::fs::create_dir_all(src.join("espeak-ng-data")).unwrap();
let bin = src.join("piper");
std::fs::write(&bin, b"BIN").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&bin).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&bin, perms).unwrap();
}
std::fs::write(src.join("espeak-ng"), b"E").unwrap();
std::fs::write(
src.join("espeak-ng-data").join("phonemes.dict"),
b"P",
)
.unwrap();
std::fs::write(src.join("libtashkeel_model.ort"), b"T").unwrap();
let dst = tmp.path().join("cache").join("piper-linux-x86_64");
install_tree(&src, &dst).unwrap();
assert_eq!(std::fs::read(dst.join("piper")).unwrap(), b"BIN");
assert_eq!(std::fs::read(dst.join("espeak-ng")).unwrap(), b"E");
assert_eq!(
std::fs::read(
dst.join("espeak-ng-data").join("phonemes.dict"),
)
.unwrap(),
b"P",
);
assert_eq!(
std::fs::read(dst.join("libtashkeel_model.ort")).unwrap(),
b"T",
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dst.join("piper"))
.unwrap()
.permissions()
.mode();
assert!(
mode & 0o111 != 0,
"expected +x preserved on installed binary, mode={mode:o}",
);
}
}
#[test]
fn install_tree_wipes_prior_installation() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("piper"), b"NEW").unwrap();
let dst = tmp.path().join("dst");
std::fs::create_dir_all(&dst).unwrap();
std::fs::write(dst.join("stale.txt"), b"old").unwrap();
install_tree(&src, &dst).unwrap();
assert!(!dst.join("stale.txt").exists(), "stale must be removed");
assert_eq!(std::fs::read(dst.join("piper")).unwrap(), b"NEW");
}
#[test]
fn download_orchestrator_picks_asset_and_installs() {
let tmp = tempfile::tempdir().unwrap();
let plat = Platform::from_consts("linux", "x86_64").unwrap();
let archive_src = make_tarball(tmp.path(), "piper");
let archive_bytes = std::fs::read(&archive_src).unwrap();
let json_called = std::sync::atomic::AtomicBool::new(false);
let asset_called = std::sync::atomic::AtomicBool::new(false);
let fetch_json = |url: &str| -> Result<Vec<u8>, PiperUnavailable> {
json_called.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(url.contains("rhasspy/piper"));
Ok(FIXTURE_RELEASE_JSON.to_vec())
};
let fetch_bytes = |url: &str, dest: &Path| -> Result<(), PiperUnavailable> {
asset_called.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(url.ends_with("piper_amd64.tar.gz"));
std::fs::write(dest, &archive_bytes).map_err(|e| {
PiperUnavailable::DownloadFailed(format!("write: {e}"))
})
};
let bin = download_piper_binary(
&plat,
tmp.path(),
fetch_json,
fetch_bytes,
)
.unwrap();
assert_eq!(
bin,
tmp.path()
.join("piper-linux-x86_64")
.join("piper"),
);
assert!(bin.exists());
assert_eq!(std::fs::read(&bin).unwrap(), b"fake-binary");
assert!(json_called.load(std::sync::atomic::Ordering::Relaxed));
assert!(asset_called.load(std::sync::atomic::Ordering::Relaxed));
}
#[test]
fn download_orchestrator_surfaces_asset_not_found() {
let tmp = tempfile::tempdir().unwrap();
let plat = Platform::from_consts("linux", "x86_64").unwrap();
let empty_for_linux = br#"{
"tag_name": "2024.01.01",
"assets": [
{
"name": "piper_macos_aarch64.tar.gz",
"browser_download_url": "https://example.test/x",
"size": 1
}
]
}"#;
let fetch_json = |_url: &str| -> Result<Vec<u8>, PiperUnavailable> {
Ok(empty_for_linux.to_vec())
};
let fetch_bytes = |_url: &str, _dest: &Path| -> Result<(), PiperUnavailable> {
panic!("fetch_bytes must not be called when asset selection fails");
};
let err = download_piper_binary(
&plat,
tmp.path(),
fetch_json,
fetch_bytes,
)
.unwrap_err();
match err {
PiperUnavailable::AssetNotFound { tag, platform } => {
assert_eq!(tag, "2024.01.01");
assert_eq!(platform, "linux-x86_64");
}
other => panic!("expected AssetNotFound, got: {other:?}"),
}
}
#[test]
fn download_orchestrator_propagates_fetch_failure() {
let tmp = tempfile::tempdir().unwrap();
let plat = Platform::from_consts("linux", "x86_64").unwrap();
let fetch_json = |_url: &str| -> Result<Vec<u8>, PiperUnavailable> {
Err(PiperUnavailable::DownloadFailed("curl 7".into()))
};
let fetch_bytes = |_url: &str, _dest: &Path| -> Result<(), PiperUnavailable> {
panic!("must not call asset fetch when manifest fetch fails");
};
let err = download_piper_binary(
&plat,
tmp.path(),
fetch_json,
fetch_bytes,
)
.unwrap_err();
assert!(matches!(err, PiperUnavailable::DownloadFailed(_)));
}
}