n5i-plugin-common 0.11.5

API definitions for developing n5i plugins.
Documentation
// SPDX-FileCopyrightText: 2024-2026 The n5i Project
//
// SPDX-License-Identifier: AGPL-3.0-or-later

use std::{
    collections::HashMap,
    os::unix::fs::{MetadataExt, PermissionsExt},
    path::Path,
};

use flate2::write::GzEncoder;
use n5i_plugin_proto::api::MultiLanguageItem;
use serde::{Deserialize, Serialize};

use tar::Header;
use tempfile::TempDir;

#[tonic::async_trait]
pub trait AppRepo<Src: Send + Sync + TryFrom<HashMap<String, String>>> {
    /// Gets metadata about this repository, such as the name, description, icon and developers.
    async fn get_store_metadata(
        &self,
        src: &State,
    ) -> Result<n5i::app_stores::StoreMetadata, tonic::Status>;

    /// Downloads all apps (or at least their metadata, depending on the app format)
    /// from a store, optionally skipping some apps.
    async fn download_apps_metadata(
        &self,
        src: &State,
        skip: &[String],
        target: &Path,
    ) -> Result<(), tonic::Status>;

    /// Downloads the latest version of an app to a specified target path.
    /// Also downloads any necessary data required to run the app.
    async fn download_app(
        &self,
        src: &State,
        app: &str,
        version: Option<semver::Version>,
        target: &Path,
    ) -> Result<(), tonic::Status>;

    /// Get available versions for a given app id.
    async fn get_available_versions(
        &self,
        src: &State,
        app: &str,
    ) -> Result<Vec<semver::Version>, tonic::Status>;

    /// Get details about how to configure a source for this app repo type.
    /// Uses the same format as app settings.
    fn get_source_config_schema(&self) -> n5i_apps::metadata::Settings;
}

pub fn list_dir_recursive(
    dir: &Path,
) -> std::pin::Pin<
    Box<dyn std::future::Future<Output = Result<Vec<String>, tonic::Status>> + Send + '_>,
> {
    Box::pin(async move {
        let mut entries = Vec::new();
        let mut read_dir = tokio::fs::read_dir(dir)
            .await
            .map_err(|e| tonic::Status::internal(format!("Failed to read directory: {e}")))?;
        while let Some(entry) = read_dir
            .next_entry()
            .await
            .map_err(|e| tonic::Status::internal(format!("Failed to read directory entry: {e}")))?
        {
            let path = entry.path();
            if path.is_dir() {
                entries.push(path.to_string_lossy().to_string());
                entries.extend(list_dir_recursive(&path).await?);
            } else {
                entries.push(path.to_string_lossy().to_string());
            }
        }
        Ok(entries)
    })
}

pub async fn compress_dir(dir: &Path) -> Result<Vec<u8>, tonic::Status> {
    let output = Vec::new();
    let gz_encoder = GzEncoder::new(output, flate2::Compression::default());
    let mut tar = tar::Builder::new(gz_encoder);
    for entry in list_dir_recursive(dir).await? {
        let path = Path::new(&entry);
        let mut header = Header::new_gnu();
        header.set_size(path.metadata()?.len());
        header.set_mode(path.metadata()?.permissions().mode());
        header.set_uid(path.metadata()?.uid() as u64);
        header.set_gid(path.metadata()?.gid() as u64);
        header.set_mtime(
            path.metadata()?
                .modified()?
                .duration_since(std::time::SystemTime::UNIX_EPOCH)
                .map_err(|e| {
                    tonic::Status::internal(format!("Failed to get file modification time: {e}"))
                })?
                .as_secs(),
        );
        // Set path, but strip the dir prefix
        header.set_path(path.strip_prefix(dir).map_err(|e| {
            tonic::Status::internal(format!("Failed to strip directory prefix: {e}"))
        })?)?;
        header.set_cksum();
        if path.is_dir() {
            header.set_entry_type(tar::EntryType::Directory);
            header.set_size(0);
            header.set_cksum();
            tar.append(&header, &mut std::io::empty())?;
        } else if path.is_symlink() {
            // Ensure symlink doesn't point outside the directory
            let link_path = path.read_link()?;
            if !link_path.starts_with(dir) {
                return Err(tonic::Status::internal("Path traversal attack detected"));
            }
            header.set_entry_type(tar::EntryType::Symlink);
            header.set_link_name(path.read_link()?)?;
            header.set_cksum();
            tar.append(&header, &mut std::io::empty())?;
        } else {
            let file = std::fs::File::open(path)?;
            header.set_cksum();
            tar.append(&header, file)?;
        }
    }
    tar.finish()?;
    Ok(tar.into_inner()?.finish()?)
}

pub struct AppRepoWrapper<
    State: Send + Sync + Serialize + for<'de> Deserialize<'de>,
    Src: Send + Sync + TryFrom<HashMap<String, String>>,
    T: AppRepo<State, Src> + Send + Sync + 'static,
>(T, std::marker::PhantomData<(State, Src)>);

impl<
    State: Send + Sync + Serialize + for<'de> Deserialize<'de>,
    Src: Send + Sync + TryFrom<HashMap<String, String>>,
    T: AppRepo<State, Src> + Send + Sync + 'static,
> AppRepoWrapper<State, Src, T>
{
    pub fn new(plugin: T) -> Self {
        Self(plugin, std::marker::PhantomData)
    }
}

#[tonic::async_trait]
impl<
    State: Send + Sync + Serialize + for<'de> Deserialize<'de> + 'static,
    Src: Send + Sync + TryFrom<HashMap<String, String>> + 'static,
    T: AppRepo<State, Src> + Send + Sync + 'static,
> crate::api::source_plugin_server::SourcePlugin for AppRepoWrapper<State, Src, T>
{
    async fn get_app_store_metadata(
        &self,
        request: tonic::Request<crate::api::GetAppStoreMetadataRequest>,
    ) -> Result<tonic::Response<crate::api::GetAppStoreMetadataResponse>, tonic::Status> {
        let request = request.into_inner();
        let mut ctx = RequestCtx::new(
            Src::try_from(request.source)
                .map_err(|_| tonic::Status::invalid_argument("Failed to parse source"))?,
            None,
        );
        let metadata = self.0.get_store_metadata(&mut ctx).await?;
        Ok(tonic::Response::new(
            crate::api::GetAppStoreMetadataResponse {
                name: Some(MultiLanguageItem {
                    text: metadata.name.0.into_iter().collect(),
                }),
                description: Some(MultiLanguageItem {
                    text: metadata.description.0.into_iter().collect(),
                }),
                tagline: Some(MultiLanguageItem {
                    text: metadata.tagline.0.into_iter().collect(),
                }),
                icon: metadata.icon,
                developers: metadata.developers.into_iter().collect(),
                license: metadata.license,
            },
        ))
    }

    async fn download_all_apps(
        &self,
        request: tonic::Request<crate::api::DownloadAppsRequest>,
    ) -> Result<tonic::Response<crate::api::DownloadAppsResponse>, tonic::Status> {
        let request = request.into_inner();
        let state: State = serde_json::from_str(&request.state)
            .map_err(|_| tonic::Status::invalid_argument("Failed to parse state"))?;
        let mut ctx = RequestCtx::new(
            Src::try_from(request.source)
                .map_err(|_| tonic::Status::invalid_argument("Failed to parse source"))?,
            Some(state),
        );
        let tempdir = TempDir::new().map_err(|e| {
            tonic::Status::internal(format!("Failed to create temporary directory: {e}"))
        })?;
        self.0
            .download_apps_metadata(&mut ctx, &request.skip_apps, tempdir.path())
            .await?;
        Ok(tonic::Response::new(crate::api::DownloadAppsResponse {
            apps_directory: compress_dir(tempdir.path()).await?,
            state: serde_json::to_string(ctx.state())
                .map_err(|e| tonic::Status::internal(format!("Failed to serialize state: {e}")))?,
        }))
    }

    async fn download_app(
        &self,
        request: tonic::Request<crate::api::DownloadAppRequest>,
    ) -> Result<tonic::Response<crate::api::DownloadAppResponse>, tonic::Status> {
        let request = request.into_inner();
        let state: State = serde_json::from_str(&request.state)
            .map_err(|_| tonic::Status::invalid_argument("Failed to parse state"))?;
        let mut ctx = RequestCtx::new(
            Src::try_from(request.source)
                .map_err(|_| tonic::Status::invalid_argument("Failed to parse source"))?,
            Some(state),
        );
        let tmpdir = TempDir::new().map_err(|e| {
            tonic::Status::internal(format!("Failed to create temporary directory: {e}"))
        })?;
        let version = if let Some(version_str) = request.app_version {
            Some(semver::Version::parse(&version_str).map_err(|e| {
                tonic::Status::invalid_argument(format!("Failed to parse version: {e}"))
            })?)
        } else {
            None
        };
        self.0
            .download_app(&mut ctx, &request.app_id, version, tmpdir.path())
            .await?;
        Ok(tonic::Response::new(crate::api::DownloadAppResponse {
            app_directory: compress_dir(tmpdir.path()).await?,
            state: serde_json::to_string(ctx.state())
                .map_err(|e| tonic::Status::internal(format!("Failed to serialize state: {e}")))?,
        }))
    }

    async fn get_app_versions(
        &self,
        request: tonic::Request<crate::api::GetAppVersionsRequest>,
    ) -> Result<tonic::Response<crate::api::GetAppVersionsResponse>, tonic::Status> {
        let request = request.into_inner();
        let state: State = serde_json::from_str(&request.state)
            .map_err(|_| tonic::Status::invalid_argument("Failed to parse state"))?;
        let mut ctx = RequestCtx::new(
            Src::try_from(request.source)
                .map_err(|_| tonic::Status::invalid_argument("Failed to parse source"))?,
            Some(state),
        );
        let versions = self
            .0
            .get_available_versions(&mut ctx, &request.app_id)
            .await?;
        Ok(tonic::Response::new(crate::api::GetAppVersionsResponse {
            versions: versions.into_iter().map(|v| v.to_string()).collect(),
            state: serde_json::to_string(ctx.state())
                .map_err(|e| tonic::Status::internal(format!("Failed to serialize state: {e}")))?,
        }))
    }
}