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>>> {
async fn get_store_metadata(
&self,
src: &State,
) -> Result<n5i::app_stores::StoreMetadata, tonic::Status>;
async fn download_apps_metadata(
&self,
src: &State,
skip: &[String],
target: &Path,
) -> Result<(), tonic::Status>;
async fn download_app(
&self,
src: &State,
app: &str,
version: Option<semver::Version>,
target: &Path,
) -> Result<(), tonic::Status>;
async fn get_available_versions(
&self,
src: &State,
app: &str,
) -> Result<Vec<semver::Version>, tonic::Status>;
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(),
);
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() {
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}")))?,
}))
}
}