regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use std::{fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path};

use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::Client;
use sha2::{Digest, Sha256};

use crate::domain::errors::{AgentError, AgentResult, ErrorCode};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DownloadReceipt {
    pub(crate) bytes: u64,
    pub(crate) sha256: String,
}

#[async_trait]
pub(crate) trait Downloader: Send + Sync {
    async fn download(
        &self,
        url: &str,
        destination: &Path,
        limit: u64,
    ) -> AgentResult<DownloadReceipt>;
}

#[derive(Clone)]
pub(crate) struct ReqwestDownloader {
    client: Client,
}

impl ReqwestDownloader {
    pub(crate) fn new() -> Self {
        Self {
            client: Client::new(),
        }
    }
}

#[async_trait]
impl Downloader for ReqwestDownloader {
    async fn download(
        &self,
        url: &str,
        destination: &Path,
        limit: u64,
    ) -> AgentResult<DownloadReceipt> {
        let mut created_destination = false;
        let result = async {
            let response = self
                .client
                .get(url)
                .send()
                .await
                .map_err(|_| download_failed())?;
            if !response.status().is_success()
                || response
                    .content_length()
                    .is_some_and(|length| length > limit)
            {
                return Err(download_failed());
            }
            let mut file = fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .mode(0o600)
                .open(destination)
                .map_err(|_| download_failed())?;
            created_destination = true;
            let mut total = 0_u64;
            let mut hash = Sha256::new();
            let mut stream = response.bytes_stream();
            while let Some(chunk) = stream.next().await {
                let chunk = chunk.map_err(|_| download_failed())?;
                total = total
                    .checked_add(chunk.len() as u64)
                    .filter(|total| *total <= limit)
                    .ok_or_else(download_failed)?;
                file.write_all(&chunk).map_err(|_| download_failed())?;
                hash.update(&chunk);
            }
            file.sync_all().map_err(|_| download_failed())?;
            Ok(DownloadReceipt {
                bytes: total,
                sha256: hex_digest(&hash.finalize()),
            })
        }
        .await;
        if result.is_err() && created_destination {
            let _ = fs::remove_file(destination);
        }
        result
    }
}

fn download_failed() -> AgentError {
    AgentError::new(ErrorCode::SkillDownloadFailed, "managed download failed")
}

fn hex_digest(bytes: &[u8]) -> String {
    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}