use crate::paths::Paths;
use anyhow::{anyhow, bail, Context, Result};
use flate2::read::GzDecoder;
use sha2::{Digest, Sha256};
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
const REPO: &str = "RyuuNeko1107/ja-furigana-dict";
const USER_AGENT: &str = concat!("furigana-cli/", env!("CARGO_PKG_VERSION"));
const MAX_DOWNLOAD_BYTES: usize = 50 * 1024 * 1024;
const MAX_UNCOMPRESSED_TOTAL: u64 = 200 * 1024 * 1024;
const MAX_PER_ENTRY_BYTES: u64 = 10 * 1024 * 1024;
const MAX_ENTRY_COUNT: usize = 50_000;
pub fn run(paths: &Paths, version: Option<&str>) -> Result<()> {
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(60))
.build()
.context("HTTP クライアント初期化失敗")?;
let tag = if let Some(v) = version {
v.to_string()
} else {
println!("最新リリースを確認中...");
resolve_latest_tag(&client)
.context("最新リリースの解決に失敗 (network or GitHub API エラー)")?
};
validate_tag_format(&tag)
.with_context(|| format!("不正な tag format (URL injection 防御のため拒否): {tag:?}"))?;
println!("取得対象: {tag}");
let archive_name = format!("furigana-dict-{tag}.tar.gz");
let tarball_url = format!("https://github.com/{REPO}/releases/download/{tag}/{archive_name}");
let sha_url = format!("{tarball_url}.sha256");
println!("ダウンロード中: {archive_name}");
let tarball = download_bytes(&client, &tarball_url)
.with_context(|| format!("tarball 取得失敗: {tarball_url}"))?;
println!(" {} bytes", tarball.len());
println!("SHA-256 sidecar 取得中...");
let expected_hex = match download_text(&client, &sha_url) {
Ok(text) => parse_sha256_sidecar(&text)
.with_context(|| format!("sha256 sidecar の parse 失敗: {sha_url}"))?,
Err(e) => {
tracing::warn!("SHA-256 sidecar 取得失敗: {e}. 検証をスキップします");
String::new()
}
};
if !expected_hex.is_empty() {
let actual_hex = sha256_hex(&tarball);
if !actual_hex.eq_ignore_ascii_case(&expected_hex) {
bail!("SHA-256 mismatch:\n expected: {expected_hex}\n actual: {actual_hex}");
}
println!("SHA-256 検証 OK");
}
println!("展開中...");
extract_to(&tarball, paths).context("tar.gz 展開失敗")?;
println!(
"完了: {tag} を {} に配置しました",
paths.data_root().display()
);
Ok(())
}
fn resolve_latest_tag(client: &reqwest::blocking::Client) -> Result<String> {
#[derive(serde::Deserialize)]
struct Release {
tag_name: String,
}
let url = format!("https://api.github.com/repos/{REPO}/releases/latest");
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send()?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
bail!(
"GitHub API {status}: {url}\n body: {}",
body.chars().take(300).collect::<String>()
);
}
let release: Release = resp.json()?;
Ok(release.tag_name)
}
pub async fn resolve_latest_tag_async() -> Result<String> {
tokio::task::spawn_blocking(|| {
let client = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(30))
.build()
.context("HTTP クライアント初期化失敗")?;
resolve_latest_tag(&client)
})
.await
.map_err(|e| anyhow!("spawn_blocking join error: {e}"))?
}
fn download_bytes(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>> {
let resp = client.get(url).send()?;
let status = resp.status();
if !status.is_success() {
bail!("HTTP {status}: {url}");
}
if let Some(len) = resp.content_length() {
if len > MAX_DOWNLOAD_BYTES as u64 {
bail!("download size {len} bytes が上限 {MAX_DOWNLOAD_BYTES} bytes を超過: {url}");
}
}
let bytes = resp.bytes()?.to_vec();
if bytes.len() > MAX_DOWNLOAD_BYTES {
bail!(
"download size {} bytes が上限 {MAX_DOWNLOAD_BYTES} bytes を超過 (post-check): {url}",
bytes.len()
);
}
Ok(bytes)
}
fn download_text(client: &reqwest::blocking::Client, url: &str) -> Result<String> {
let resp = client.get(url).send()?;
let status = resp.status();
if !status.is_success() {
bail!("HTTP {status}: {url}");
}
Ok(resp.text()?)
}
fn parse_sha256_sidecar(text: &str) -> Result<String> {
let first = text
.lines()
.next()
.ok_or_else(|| anyhow!("空の sha256 sidecar"))?
.trim();
let hex = first
.split_whitespace()
.next()
.ok_or_else(|| anyhow!("sha256 sidecar の形式が不正: {first:?}"))?;
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("sha256 hex の長さ/文字種が不正: {hex:?}");
}
Ok(hex.to_string())
}
fn validate_tag_format(tag: &str) -> Result<()> {
if tag.is_empty() || tag.len() > 64 {
bail!("tag 長が範囲外: {} 文字 (許容 1..=64)", tag.len());
}
if tag.contains("..") {
bail!("tag に連続 `..` を含む (path traversal 経路): {tag:?}");
}
if !tag
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
{
bail!("tag に許容外文字を含む (許容: A-Za-z0-9.-): {tag:?}");
}
Ok(())
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
fn extract_to(tarball: &[u8], paths: &Paths) -> Result<()> {
let data_root: PathBuf = paths.data_root();
let user_dir: PathBuf = paths.dict_user_dir();
let overrides: PathBuf = paths.overrides_file();
if data_root.exists() {
for entry in fs::read_dir(&data_root)? {
let path = entry?.path();
if path == user_dir || path == overrides {
continue;
}
if path.is_dir() {
fs::remove_dir_all(&path)
.with_context(|| format!("既存削除失敗: {}", path.display()))?;
} else {
fs::remove_file(&path)
.with_context(|| format!("既存削除失敗: {}", path.display()))?;
}
}
} else {
fs::create_dir_all(&data_root)?;
}
let gz = GzDecoder::new(tarball);
let mut archive = tar::Archive::new(gz);
archive.set_preserve_permissions(false);
archive.set_overwrite(true);
let canonical_root = data_root
.canonicalize()
.unwrap_or_else(|_| data_root.clone());
let mut total_uncompressed: u64 = 0;
let mut entry_count: usize = 0;
for entry in archive.entries()? {
let mut entry = entry?;
entry_count += 1;
if entry_count > MAX_ENTRY_COUNT {
bail!("archive の entry 数が上限 {MAX_ENTRY_COUNT} を超過 (現在 {entry_count})");
}
let entry_type = entry.header().entry_type();
if !entry_type.is_file() && !entry_type.is_dir() {
bail!(
"拒否された archive entry type: {:?} ({})",
entry_type,
entry.path()?.display()
);
}
let entry_size = entry.header().size().unwrap_or(0);
if entry_size > MAX_PER_ENTRY_BYTES {
bail!(
"archive entry が大きすぎる: {entry_size} bytes (上限 {MAX_PER_ENTRY_BYTES} bytes): {}",
entry.path()?.display()
);
}
total_uncompressed = total_uncompressed.saturating_add(entry_size);
if total_uncompressed > MAX_UNCOMPRESSED_TOTAL {
bail!(
"archive 展開合計サイズが上限 {MAX_UNCOMPRESSED_TOTAL} bytes を超過 (現在 {total_uncompressed} bytes)"
);
}
let entry_path = entry.path()?.into_owned();
let dest = if let Ok(rest) = entry_path.strip_prefix("core") {
if rest.as_os_str().is_empty() {
continue;
}
data_root.join(rest)
} else if let Ok(rest) = entry_path.strip_prefix("rules") {
if rest.as_os_str().is_empty() {
continue;
}
data_root.join(rest)
} else {
tracing::debug!("skip archive entry: {}", entry_path.display());
continue;
};
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let canonical_parent = dest
.parent()
.and_then(|p| p.canonicalize().ok())
.unwrap_or_else(|| dest.clone());
if !canonical_parent.starts_with(&canonical_root) {
bail!(
"path traversal を検出: {} は {} の外",
entry_path.display(),
data_root.display()
);
}
if entry.header().entry_type().is_dir() {
fs::create_dir_all(&dest)?;
} else {
entry.unpack(&dest)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_tag_format() {
assert!(validate_tag_format("v0.1.0").is_ok());
assert!(validate_tag_format("v0.1.0-alpha.8").is_ok());
assert!(validate_tag_format("v2026.05.07").is_ok());
assert!(validate_tag_format("").is_err());
assert!(validate_tag_format("../../etc/passwd").is_err());
assert!(validate_tag_format("v0.1.0/extra").is_err());
assert!(validate_tag_format("v0..1.0").is_err());
assert!(validate_tag_format("v0.1.0\0evil").is_err());
assert!(validate_tag_format("v0.1.0:9000").is_err());
assert!(validate_tag_format(&"v".repeat(65)).is_err());
}
#[test]
fn parses_sha256_sidecar() {
let text = "8e7d1c4...abcd furigana-dict-v0.1.0.tar.gz\n";
assert!(parse_sha256_sidecar(text).is_err());
let valid = format!("{} furigana-dict-v0.1.0.tar.gz\n", "a".repeat(64));
assert_eq!(parse_sha256_sidecar(&valid).unwrap(), "a".repeat(64));
}
#[test]
fn sha256_known_vector() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
}