moine-cli 0.2.0

Command line utilities for moine validation
Documentation
use std::env;
use std::error::Error;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Duration;

use sha2::Digest;

use crate::archive::{extract_artifact_archive, move_dir, TempDir};
use crate::args::{
    download_spec_for_language, ArtifactLanguage, CacheCliOptions, CliError, DownloadCliOptions,
    WhereCliOptions,
};
use crate::commands::unidic_artifact::verify_unidic_artifact_bundle;
use crate::commands::zh_artifact::verify_zh_artifact_bundle;

const DOWNLOAD_TIMEOUT_SECS: u64 = 60;
const MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024;
const MAX_CHECKSUM_MANIFEST_BYTES: u64 = 1024 * 1024;

pub(crate) fn run_download(options: DownloadCliOptions) -> Result<(), Box<dyn Error>> {
    let cache_dir = options
        .cache_dir
        .as_deref()
        .map(PathBuf::from)
        .unwrap_or_else(default_cache_dir);
    let archive_url = options.url.as_deref().unwrap_or(options.spec.archive_url);
    let archive_name = uri_file_name(archive_url).unwrap_or(options.spec.archive_name);
    let temp = TempDir::new("moine-download")?;
    let archive_path = temp.path().join(archive_name);

    copy_uri_to_path(archive_url, &archive_path)?;
    if let Some(expected_sha256) = download_expected_sha256(&options, archive_name)? {
        let actual_sha256 = sha256_file(&archive_path)?;
        if actual_sha256 != expected_sha256 {
            return Err(Box::new(CliError::ArtifactVerificationFailed(format!(
                "checksum mismatch for {archive_name}: expected {expected_sha256}, got {actual_sha256}"
            ))));
        }
    }

    let extracted_root = extract_artifact_archive(&archive_path, &temp.path().join("extract"))?;
    let metadata = extracted_root.join("metadata.yaml");
    if !metadata.is_file() {
        return Err(Box::new(CliError::ArtifactVerificationFailed(format!(
            "downloaded artifact has no metadata.yaml: {}",
            extracted_root.display()
        ))));
    }
    verify_downloaded_bundle(options.spec.language, &metadata)?;

    fs::create_dir_all(&cache_dir)?;
    let destination = cache_dir.join(extracted_root.file_name().ok_or_else(|| {
        CliError::ArtifactVerificationFailed(format!(
            "extracted artifact root {} has no file name",
            extracted_root.display()
        ))
    })?);
    if destination.exists() {
        if !options.force {
            println!("{}", destination.display());
            return Ok(());
        }
        fs::remove_dir_all(&destination)?;
    }
    move_dir(&extracted_root, &destination)?;
    println!("{}", destination.display());
    Ok(())
}

pub(crate) fn run_download_list(options: CacheCliOptions) -> Result<(), Box<dyn Error>> {
    let cache_dir = options
        .cache_dir
        .map(PathBuf::from)
        .unwrap_or_else(default_cache_dir);
    for metadata in installed_metadata_paths(&cache_dir)? {
        if let Some(parent) = metadata.parent() {
            println!("{}", parent.display());
        }
    }
    Ok(())
}

pub(crate) fn run_download_where(options: WhereCliOptions) -> Result<(), Box<dyn Error>> {
    let cache_dir = options
        .cache_dir
        .map(PathBuf::from)
        .unwrap_or_else(default_cache_dir);
    let Some(language) = options.language else {
        println!("{}", cache_dir.display());
        return Ok(());
    };
    let spec = download_spec_for_language(language);
    if let Some(metadata) = find_metadata_by_prefix(&cache_dir, spec.artifact_name)? {
        if let Some(parent) = metadata.parent() {
            println!("{}", parent.display());
            return Ok(());
        }
    }
    println!("{}", cache_dir.join(spec.artifact_name).display());
    Ok(())
}

pub(crate) fn default_cache_dir() -> PathBuf {
    if let Some(cache_dir) = env::var_os("MOINE_CACHE_DIR") {
        return PathBuf::from(cache_dir);
    }
    if let Some(cache_home) = env::var_os("XDG_CACHE_HOME") {
        return PathBuf::from(cache_home).join("moine").join("dictionaries");
    }
    if let Some(home) = env::var_os("HOME") {
        return PathBuf::from(home)
            .join(".cache")
            .join("moine")
            .join("dictionaries");
    }
    PathBuf::from(".moine").join("dictionaries")
}

pub(crate) fn uri_file_name(uri: &str) -> Option<&str> {
    uri.rsplit('/')
        .next()
        .filter(|name| !name.is_empty() && !name.contains('\\'))
}

pub(crate) fn copy_uri_to_path(uri: &str, output: &Path) -> Result<(), Box<dyn Error>> {
    if uri.starts_with("http://") || uri.starts_with("https://") {
        let response = ureq::get(uri)
            .timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
            .call()?;
        let mut reader = response.into_reader();
        let mut file = fs::File::create(output)?;
        let copied = std::io::copy(&mut reader.by_ref().take(MAX_DOWNLOAD_BYTES + 1), &mut file)?;
        if copied > MAX_DOWNLOAD_BYTES {
            return Err(Box::new(CliError::ArtifactVerificationFailed(format!(
                "download exceeded maximum size of {MAX_DOWNLOAD_BYTES} bytes"
            ))));
        }
        return Ok(());
    }
    if let Some(path) = uri.strip_prefix("file://") {
        fs::copy(path, output)?;
        return Ok(());
    }
    fs::copy(uri, output)?;
    Ok(())
}

pub(crate) fn read_uri_text(uri: &str) -> Result<String, Box<dyn Error>> {
    if uri.starts_with("http://") || uri.starts_with("https://") {
        let response = ureq::get(uri)
            .timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
            .call()?;
        let mut text = String::new();
        let read = response
            .into_reader()
            .take(MAX_CHECKSUM_MANIFEST_BYTES + 1)
            .read_to_string(&mut text)?;
        if read as u64 > MAX_CHECKSUM_MANIFEST_BYTES {
            return Err(Box::new(CliError::ArtifactVerificationFailed(format!(
                "checksum manifest exceeded maximum size of {MAX_CHECKSUM_MANIFEST_BYTES} bytes"
            ))));
        }
        return Ok(text);
    }
    if let Some(path) = uri.strip_prefix("file://") {
        return Ok(fs::read_to_string(path)?);
    }
    Ok(fs::read_to_string(uri)?)
}

pub(crate) fn expected_sha256(
    checksum_url: &str,
    archive_name: &str,
) -> Result<String, Box<dyn Error>> {
    for line in read_uri_text(checksum_url)?.lines() {
        let mut parts = line.split_whitespace();
        let Some(digest) = parts.next() else {
            continue;
        };
        let Some(label) = parts.next() else {
            continue;
        };
        if parts.next().is_some() {
            continue;
        }
        if label == archive_name
            || Path::new(label).file_name().and_then(|name| name.to_str()) == Some(archive_name)
        {
            return Ok(digest.to_ascii_lowercase());
        }
    }
    Err(Box::new(CliError::ArtifactVerificationFailed(format!(
        "{archive_name} not found in checksum manifest: {checksum_url}"
    ))))
}

pub(crate) fn download_expected_sha256(
    options: &DownloadCliOptions,
    archive_name: &str,
) -> Result<Option<String>, Box<dyn Error>> {
    if let Some(value) = &options.sha256 {
        return Ok(Some(value.to_ascii_lowercase()));
    }
    if let Some(checksum_url) = options
        .checksum_url
        .as_deref()
        .or(options.spec.checksum_url)
    {
        return Ok(Some(expected_sha256(checksum_url, archive_name)?));
    }
    Ok(None)
}

pub(crate) fn sha256_file(path: &Path) -> Result<String, Box<dyn Error>> {
    let mut file = fs::File::open(path)?;
    let mut digest = sha2::Sha256::new();
    let mut buffer = [0_u8; 1024 * 64];
    loop {
        let read = file.read(&mut buffer)?;
        if read == 0 {
            break;
        }
        sha2::Digest::update(&mut digest, &buffer[..read]);
    }
    Ok(format!("{:x}", sha2::Digest::finalize(digest)))
}

pub(crate) fn verify_downloaded_bundle(
    language: ArtifactLanguage,
    metadata: &Path,
) -> Result<(), Box<dyn Error>> {
    let metadata = metadata.to_str().ok_or_else(|| {
        CliError::ArtifactVerificationFailed("metadata path is not UTF-8".to_string())
    })?;
    match language {
        ArtifactLanguage::Japanese | ArtifactLanguage::JapaneseSudachi => {
            verify_unidic_artifact_bundle(metadata, None, false)?;
        }
        ArtifactLanguage::Chinese => {
            verify_zh_artifact_bundle(metadata, None)?;
        }
    }
    Ok(())
}

pub(crate) fn installed_metadata_paths(cache_dir: &Path) -> Result<Vec<PathBuf>, Box<dyn Error>> {
    let mut metadata_paths = Vec::new();
    if !cache_dir.is_dir() {
        return Ok(metadata_paths);
    }
    let root_metadata = cache_dir.join("metadata.yaml");
    if root_metadata.is_file() {
        metadata_paths.push(root_metadata);
    }
    for entry in fs::read_dir(cache_dir)? {
        let path = entry?.path();
        let metadata = path.join("metadata.yaml");
        if path.is_dir() && metadata.is_file() {
            metadata_paths.push(metadata);
        }
    }
    metadata_paths.sort();
    metadata_paths.dedup();
    Ok(metadata_paths)
}

pub(crate) fn find_metadata_by_prefix(
    cache_dir: &Path,
    artifact_name: &str,
) -> Result<Option<PathBuf>, Box<dyn Error>> {
    Ok(installed_metadata_paths(cache_dir)?
        .into_iter()
        .find(|metadata| {
            metadata
                .parent()
                .and_then(|parent| parent.file_name())
                .and_then(|name| name.to_str())
                .is_some_and(|name| name.starts_with(artifact_name))
        }))
}