use std::collections::BTreeSet;
use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use anyhow::{Context, Result};
use flate2::read::GzDecoder;
use crate::skills::install::is_safe_path;
use super::PluginInstallError;
use super::stage::{StagedPlugin, fresh_staging_dir, validate_staged};
pub(super) fn stage_tarball(
bytes: &[u8],
user_plugins_dir: &Path,
max_size: u64,
) -> Result<StagedPlugin> {
let scan = scan_tarball(bytes, max_size)?;
let staged_path = fresh_staging_dir(user_plugins_dir)?;
let result = extract_into(&scan, bytes, &staged_path, max_size)
.and_then(|()| validate_staged(&staged_path));
match result {
Ok((name, content_hash)) => Ok(StagedPlugin {
name,
staged_path,
content_hash,
}),
Err(error) => {
let _ = fs::remove_dir_all(&staged_path);
Err(error)
}
}
}
#[derive(Debug)]
pub(super) struct TarballScan {
plugin_root: String,
}
pub(super) fn scan_tarball(bytes: &[u8], max_size: u64) -> Result<TarballScan> {
let cursor = std::io::Cursor::new(bytes);
let gz = GzDecoder::new(cursor);
let mut archive = tar::Archive::new(gz);
let mut total_size: u64 = 0;
let mut manifest_paths: Vec<String> = Vec::new();
for entry in archive
.entries()
.context("failed to read tar entries (corrupt archive?)")?
{
let entry = entry.context("failed to read tar entry")?;
let header = entry.header().clone();
let path = entry
.path()
.context("tar entry has invalid path")?
.to_path_buf();
let path_str = path.to_string_lossy().into_owned();
if !is_safe_path(&path) {
return Err(PluginInstallError::PathTraversal(path_str).into());
}
if let Ok(size) = header.size() {
total_size = total_size.saturating_add(size);
if total_size > max_size {
return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
}
}
if header.entry_type().is_file()
&& path.file_name().is_some_and(|name| {
name == std::ffi::OsStr::new(crate::plugins::agent_plugin::PLUGIN_JSON_NAME)
|| name
== std::ffi::OsStr::new(crate::plugins::agent_plugin::KIMI_PLUGIN_JSON_NAME)
|| name == std::ffi::OsStr::new(crate::plugins::agent_plugin::PLUGIN_TOML_NAME)
})
{
manifest_paths.push(path_str);
}
}
let roots: BTreeSet<String> = manifest_paths
.iter()
.map(|manifest| {
manifest
.rsplit_once('/')
.map(|(dir, _)| dir.to_string())
.unwrap_or_default()
})
.collect();
if roots.len() != 1 {
return Err(PluginInstallError::PluginTomlRoots(roots.len()).into());
}
let plugin_root = roots.into_iter().next().unwrap_or_default();
Ok(TarballScan { plugin_root })
}
fn extract_into(scan: &TarballScan, bytes: &[u8], dest: &Path, max_size: u64) -> Result<()> {
let cursor = std::io::Cursor::new(bytes);
let gz = GzDecoder::new(cursor);
let mut archive = tar::Archive::new(gz);
let mut total_size: u64 = 0;
for entry in archive
.entries()
.context("failed to read tar entries (corrupt archive?)")?
{
let mut entry = entry.context("failed to read tar entry")?;
let header = entry.header().clone();
let entry_type = header.entry_type();
let path = entry
.path()
.context("tar entry has invalid path")?
.to_path_buf();
let path_str = path.to_string_lossy().into_owned();
if !is_safe_path(&path) {
return Err(PluginInstallError::PathTraversal(path_str).into());
}
let stripped = if scan.plugin_root.is_empty() {
path_str.clone()
} else if path_str == scan.plugin_root {
String::new()
} else if let Some(rest) = path_str.strip_prefix(&format!("{}/", scan.plugin_root)) {
rest.to_string()
} else {
continue;
};
if stripped.is_empty() {
continue;
}
let stripped_path = Path::new(&stripped);
if !is_safe_path(stripped_path) {
return Err(PluginInstallError::PathTraversal(stripped).into());
}
if entry_type.is_symlink() || entry_type.is_hard_link() {
return Err(PluginInstallError::SymlinkRejected.into());
}
let target = dest.join(stripped_path);
let target_components: Vec<_> = target.components().collect();
let dest_components: Vec<_> = dest.components().collect();
if !target_components.starts_with(dest_components.as_slice()) {
return Err(PluginInstallError::PathTraversal(stripped).into());
}
if entry_type.is_dir() {
fs::create_dir_all(&target)
.with_context(|| format!("failed to create dir {}", target.display()))?;
continue;
}
if entry_type.is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create dir {}", parent.display()))?;
}
let mut buf = Vec::new();
entry
.read_to_end(&mut buf)
.with_context(|| format!("failed to read {}", path.display()))?;
total_size = total_size.saturating_add(buf.len() as u64);
if total_size > max_size {
return Err(PluginInstallError::OversizedBundle { limit: max_size }.into());
}
let mut out = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&target)
.with_context(|| format!("failed to create {}", target.display()))?;
out.write_all(&buf)
.with_context(|| format!("failed to write {}", target.display()))?;
}
}
Ok(())
}