use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use bamboo_config::PluginTrustConfig;
use bamboo_plugin::manifest::Platform;
use bamboo_plugin::{
InstallDisposition, InstalledPlugin, PluginError, PluginInstaller, PluginManifest,
PluginResult, PluginSource,
};
use ed25519_dalek::Verifier;
#[derive(Debug, Clone)]
pub enum PluginSourceInput {
LocalDir(PathBuf),
LocalArchive(PathBuf),
Url {
url: String,
sha256: Option<String>,
allow_unverified: bool,
allow_untrusted_host: bool,
allow_unsigned: bool,
},
}
#[derive(Debug)]
pub struct StagedPlugin {
pub manifest: PluginManifest,
pub plugin_dir: PathBuf,
pub source: PluginSource,
backup_dir: Option<PathBuf>,
}
impl StagedPlugin {
pub async fn commit(self) {
if let Some(backup) = self.backup_dir {
let _ = tokio::fs::remove_dir_all(&backup).await;
}
}
pub async fn rollback(self) {
let _ = tokio::fs::remove_dir_all(&self.plugin_dir).await;
if let Some(backup) = self.backup_dir {
let _ = tokio::fs::rename(&backup, &self.plugin_dir).await;
}
}
}
pub async fn stage_plugin_source(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
) -> PluginResult<StagedPlugin> {
stage_plugin_source_inner(input, plugins_root, trust, MAX_DECOMPRESSED_BYTES).await
}
#[cfg(test)]
pub(crate) async fn stage_plugin_source_with_decompressed_cap(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
max_decompressed_bytes: u64,
) -> PluginResult<StagedPlugin> {
stage_plugin_source_inner(input, plugins_root, trust, max_decompressed_bytes).await
}
async fn stage_plugin_source_inner(
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
max_decompressed_bytes: u64,
) -> PluginResult<StagedPlugin> {
tokio::fs::create_dir_all(plugins_root).await?;
let staging_dir = plugins_root.join(format!(".staging-{}", uuid::Uuid::new_v4()));
tokio::fs::create_dir_all(&staging_dir).await?;
let staged = stage_into(&input, &staging_dir, trust, max_decompressed_bytes).await;
let (manifest, source) = match staged {
Ok(pair) => pair,
Err(error) => {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(error);
}
};
if let Err(error) = manifest.validate() {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(error);
}
let plugin_dir = plugins_root.join(&manifest.id);
let backup_dir = if tokio::fs::try_exists(&plugin_dir).await.unwrap_or(false) {
let backup = plugins_root.join(format!(".backup-{}-{}", manifest.id, uuid::Uuid::new_v4()));
if let Err(error) = tokio::fs::rename(&plugin_dir, &backup).await {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
return Err(PluginError::Io(error));
}
Some(backup)
} else {
None
};
if let Err(rename_error) = tokio::fs::rename(&staging_dir, &plugin_dir).await {
if let Err(copy_error) = copy_dir_recursive(&staging_dir, &plugin_dir).await {
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
if let Some(backup) = &backup_dir {
let _ = tokio::fs::rename(backup, &plugin_dir).await;
}
tracing::warn!(%rename_error, %copy_error, "failed to stage plugin bundle into place");
return Err(copy_error);
}
let _ = tokio::fs::remove_dir_all(&staging_dir).await;
}
Ok(StagedPlugin {
manifest,
plugin_dir,
source,
backup_dir,
})
}
pub async fn install_plugin_from_source(
installer: &dyn PluginInstaller,
input: PluginSourceInput,
plugins_root: &Path,
trust: &PluginTrustConfig,
disposition: InstallDisposition,
) -> PluginResult<InstalledPlugin> {
let staged = stage_plugin_source(input, plugins_root, trust).await?;
let manifest = staged.manifest.clone();
let plugin_dir = staged.plugin_dir.clone();
let source = staged.source.clone();
match installer
.install(
&manifest,
&plugin_dir,
source,
disposition,
chrono::Utc::now(),
)
.await
{
Ok(entry) => {
staged.commit().await;
Ok(entry)
}
Err(error) => {
staged.rollback().await;
Err(error)
}
}
}
async fn stage_into(
input: &PluginSourceInput,
staging_dir: &Path,
trust: &PluginTrustConfig,
max_decompressed_bytes: u64,
) -> PluginResult<(PluginManifest, PluginSource)> {
match input {
PluginSourceInput::LocalDir(path) => {
copy_dir_recursive(path, staging_dir).await?;
let manifest = read_and_parse_manifest(staging_dir).await?;
Ok((manifest, PluginSource::LocalDir { path: path.clone() }))
}
PluginSourceInput::LocalArchive(path) => {
let bytes = tokio::fs::read(path).await?;
let kind = detect_archive_kind(&path.to_string_lossy()).ok_or_else(|| {
PluginError::InvalidManifest(format!(
"unsupported archive extension for '{}': expected .zip/.tar.gz/.tgz",
path.display()
))
})?;
extract_archive(
bytes,
kind,
staging_dir.to_path_buf(),
max_decompressed_bytes,
)
.await?;
flatten_if_single_subdir(staging_dir).await?;
let manifest = read_and_parse_manifest(staging_dir).await?;
Ok((manifest, PluginSource::LocalArchive { path: path.clone() }))
}
PluginSourceInput::Url {
url,
sha256,
allow_unverified,
allow_untrusted_host,
allow_unsigned,
} => {
let flags = UrlTrustFlags {
sha256: sha256.as_deref(),
allow_unverified: *allow_unverified,
allow_untrusted_host: *allow_untrusted_host,
allow_unsigned: *allow_unsigned,
};
let (manifest, verified_bundle_sha256, signed_by) =
fetch_manifest_bundle(url, flags, trust, staging_dir, max_decompressed_bytes)
.await?;
fetch_and_place_artifact(&manifest, staging_dir, max_decompressed_bytes).await?;
Ok((
manifest,
PluginSource::Url {
url: url.clone(),
sha256: verified_bundle_sha256,
allow_unverified: *allow_unverified,
allow_untrusted_host: *allow_untrusted_host,
allow_unsigned: *allow_unsigned,
signed_by,
},
))
}
}
}
struct UrlTrustFlags<'a> {
sha256: Option<&'a str>,
allow_unverified: bool,
allow_untrusted_host: bool,
allow_unsigned: bool,
}
async fn fetch_manifest_bundle(
url: &str,
flags: UrlTrustFlags<'_>,
trust: &PluginTrustConfig,
staging_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<(PluginManifest, Option<String>, Option<String>)> {
let UrlTrustFlags {
sha256,
allow_unverified,
allow_untrusted_host,
allow_unsigned,
} = flags;
if !trust.is_host_trusted(url) {
if !allow_untrusted_host {
return Err(PluginError::UntrustedHost(format!(
"refusing to install plugin bundle from '{url}': its host is not in the \
`plugin_trust.trusted_hosts` allowlist (config.json) — add a matching \
host+path prefix there, or explicitly accept the risk (CLI: \
`--allow-untrusted-host`; HTTP: `\"allow_untrusted_host\": true`)"
)));
}
tracing::warn!(
%url,
"installing plugin bundle from a host outside `plugin_trust.trusted_hosts` \
(allow_untrusted_host opt-out)"
);
}
let bytes_will_be_authenticated = !allow_unsigned || sha256.is_some();
let client = if bytes_will_be_authenticated {
http_client_following_redirects()
} else {
http_client_no_redirects()
};
let bytes = download_bytes(client, url, MAX_DOWNLOAD_BYTES).await?;
let signed_by = fetch_and_verify_signature(client, url, &bytes, &trust.trusted_keys).await;
if signed_by.is_none() {
if !allow_unsigned {
return Err(PluginError::UnsignedOrUntrustedSignature(format!(
"refusing to install plugin bundle from '{url}': it is unsigned, or its \
'{url}.sig' does not verify against any key in `plugin_trust.trusted_keys` \
(config.json) — publish a signature from a trusted key, or explicitly accept \
the risk (CLI: `--allow-unsigned`; HTTP: `\"allow_unsigned\": true`)"
)));
}
tracing::warn!(
%url,
"installing an unsigned (or untrusted-signature) plugin bundle (allow_unsigned opt-out)"
);
}
if sha256.is_none() && !allow_unverified && signed_by.is_none() {
return Err(PluginError::ChecksumRequired(format!(
"refusing to install plugin bundle from '{url}' without a checksum — pass the \
bundle's sha256 (from the release page / a trusted source) to verify it before \
install (CLI: `--sha256 <hex>`; HTTP: `\"sha256\": \"<hex>\"` on the url source), \
or explicitly accept the risk of an unverified download (CLI: \
`--allow-unverified`; HTTP: `\"allow_unverified\": true`)"
)));
}
let verified_sha256 = match sha256 {
Some(expected) => {
let actual = sha256_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
return Err(PluginError::BundleVerificationFailed(format!(
"sha256 mismatch for plugin bundle '{url}': expected {expected}, downloaded \
bytes hash to {actual} — refusing to unpack (the bundle may be tampered, \
corrupted, or the wrong sha256 was supplied)"
)));
}
Some(actual)
}
None => {
if signed_by.is_none() {
tracing::warn!(
%url,
"installing plugin bundle from a URL with no checksum verification \
(allow_unverified opt-out) — the download is trusted on HTTPS alone"
);
}
None
}
};
let manifest = if let Some(kind) = detect_archive_kind(url) {
extract_archive(
bytes,
kind,
staging_dir.to_path_buf(),
max_decompressed_bytes,
)
.await?;
flatten_if_single_subdir(staging_dir).await?;
read_and_parse_manifest(staging_dir).await?
} else {
let raw = String::from_utf8(bytes).map_err(|_| {
PluginError::InvalidManifest(format!("manifest at '{url}' is not valid UTF-8"))
})?;
tokio::fs::create_dir_all(staging_dir).await?;
tokio::fs::write(staging_dir.join("plugin.json"), &raw).await?;
PluginManifest::parse_str(&raw)?
};
Ok((manifest, verified_sha256, signed_by))
}
async fn fetch_and_verify_signature(
client: &reqwest::Client,
url: &str,
bundle_bytes: &[u8],
trusted_keys: &[bamboo_config::TrustedKey],
) -> Option<String> {
let sig_url = format!("{url}.sig");
let sig_bytes = download_bytes(client, &sig_url, MAX_SIGNATURE_DOWNLOAD_BYTES)
.await
.ok()?;
let sig_text = String::from_utf8(sig_bytes).ok()?;
let sig_raw = hex::decode(sig_text.trim()).ok()?;
let sig_array: [u8; 64] = sig_raw.try_into().ok()?;
let signature = ed25519_dalek::Signature::from_bytes(&sig_array);
for key in trusted_keys {
if !key.algorithm.eq_ignore_ascii_case("ed25519") {
continue;
}
let Ok(pub_raw) = hex::decode(&key.public_key) else {
continue;
};
let Ok(pub_array) = <[u8; 32]>::try_from(pub_raw.as_slice()) else {
continue;
};
let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pub_array) else {
continue;
};
if verifying_key.verify(bundle_bytes, &signature).is_ok() {
return Some(key.label.clone());
}
}
None
}
async fn fetch_and_place_artifact(
manifest: &PluginManifest,
staging_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
let Some(platform) = Platform::current() else {
return Ok(());
};
let Some(artifact) = manifest.artifacts.get(platform.as_str()) else {
return Ok(());
};
let bytes = download_bytes(
http_client_following_redirects(),
&artifact.url,
MAX_DOWNLOAD_BYTES,
)
.await?;
let actual_sha256 = sha256_hex(&bytes);
if !actual_sha256.eq_ignore_ascii_case(&artifact.sha256) {
return Err(PluginError::ArtifactVerificationFailed(format!(
"sha256 mismatch for '{}': manifest declares {}, downloaded bytes hash to {}",
artifact.url, artifact.sha256, actual_sha256
)));
}
let kind = detect_archive_kind(&artifact.url).ok_or_else(|| {
PluginError::InvalidManifest(format!(
"artifact url '{}' is not a .zip/.tar.gz/.tgz",
artifact.url
))
})?;
let scratch_dir = staging_dir.join(format!(".artifact-scratch-{}", platform.as_str()));
extract_archive(bytes, kind, scratch_dir.clone(), max_decompressed_bytes).await?;
let expected_name = if matches!(platform, Platform::Windows) {
format!("{}.exe", manifest.id)
} else {
manifest.id.clone()
};
let source_bin = scratch_dir.join(&expected_name);
if !tokio::fs::try_exists(&source_bin).await.unwrap_or(false) {
let _ = tokio::fs::remove_dir_all(&scratch_dir).await;
return Err(PluginError::InvalidManifest(format!(
"artifact archive for platform '{}' does not contain the expected root executable '{}'",
platform.as_str(),
expected_name
)));
}
let dest_dir = staging_dir.join("bin").join(platform.as_str());
tokio::fs::create_dir_all(&dest_dir).await?;
let dest_bin = dest_dir.join(&expected_name);
move_file(&source_bin, &dest_bin).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = tokio::fs::metadata(&dest_bin).await?.permissions();
perms.set_mode(0o755);
tokio::fs::set_permissions(&dest_bin, perms).await?;
}
let _ = tokio::fs::remove_dir_all(&scratch_dir).await;
Ok(())
}
async fn move_file(source: &Path, dest: &Path) -> PluginResult<()> {
if tokio::fs::rename(source, dest).await.is_ok() {
return Ok(());
}
let data = tokio::fs::read(source).await?;
tokio::fs::write(dest, data).await?;
tokio::fs::remove_file(source).await?;
Ok(())
}
fn http_client_following_redirects() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.expect("a reqwest client with only a redirect policy set always builds")
})
}
fn http_client_no_redirects() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("a reqwest client with only a redirect policy set always builds")
})
}
const MAX_DOWNLOAD_BYTES: u64 = 256 * 1024 * 1024;
const MAX_SIGNATURE_DOWNLOAD_BYTES: u64 = 4 * 1024;
const MAX_DECOMPRESSED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
async fn download_bytes(
client: &reqwest::Client,
url: &str,
max_bytes: u64,
) -> PluginResult<Vec<u8>> {
use futures::StreamExt;
let response =
client.get(url).send().await.map_err(|error| {
PluginError::Registration(format!("failed to fetch '{url}': {error}"))
})?;
if response.status().is_redirection() {
let status = response.status();
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let target = location.as_deref().unwrap_or("(unspecified)");
return Err(PluginError::RedirectRefused(format!(
"refused to follow an HTTP redirect ({status}) from '{url}' to '{target}': for an \
unverified install (no signature, no checksum) the approved host must serve the \
bytes directly, so redirects are not followed — install from the canonical/final \
URL, or provide a signature / `--sha256` (which authenticates the bytes regardless \
of which host serves them), or add the redirect target's host to \
`plugin_trust.trusted_hosts`"
)));
}
let response = response.error_for_status().map_err(|error| {
PluginError::Registration(format!("'{url}' returned an error status: {error}"))
})?;
if let Some(len) = response.content_length() {
if len > max_bytes {
return Err(PluginError::Registration(format!(
"'{url}' advertises a {len}-byte body, over the {max_bytes}-byte download cap; \
refusing"
)));
}
}
let mut stream = response.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|error| {
PluginError::Registration(format!("failed to read response body of '{url}': {error}"))
})?;
if buffer.len() as u64 + chunk.len() as u64 > max_bytes {
return Err(PluginError::Registration(format!(
"'{url}' streamed more than the {max_bytes}-byte download cap; aborting"
)));
}
buffer.extend_from_slice(&chunk);
}
Ok(buffer)
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[derive(Debug, Clone, Copy)]
enum ArchiveKind {
Zip,
TarGz,
}
fn detect_archive_kind(name_or_url: &str) -> Option<ArchiveKind> {
let lower = name_or_url.to_ascii_lowercase();
let lower = lower.split(['?', '#']).next().unwrap_or(&lower).to_string();
if lower.ends_with(".zip") {
Some(ArchiveKind::Zip)
} else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
Some(ArchiveKind::TarGz)
} else {
None
}
}
async fn extract_archive(
bytes: Vec<u8>,
kind: ArchiveKind,
dest_dir: PathBuf,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
tokio::fs::create_dir_all(&dest_dir).await?;
tokio::task::spawn_blocking(move || match kind {
ArchiveKind::Zip => extract_zip_sync(&bytes, &dest_dir, max_decompressed_bytes),
ArchiveKind::TarGz => extract_targz_sync(&bytes, &dest_dir, max_decompressed_bytes),
})
.await
.map_err(|error| {
PluginError::Registration(format!("archive extraction task panicked: {error}"))
})?
}
fn copy_capped(
reader: &mut impl std::io::Read,
writer: &mut impl std::io::Write,
running_total: &mut u64,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
let mut buffer = [0u8; 64 * 1024];
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
return Ok(());
}
*running_total += bytes_read as u64;
if *running_total > max_decompressed_bytes {
return Err(PluginError::InvalidManifest(format!(
"archive expands to more than the {max_decompressed_bytes}-byte decompressed \
size cap ({running_total} bytes and counting); refusing to unpack (possible \
decompression bomb)"
)));
}
writer.write_all(&buffer[..bytes_read])?;
}
}
fn extract_zip_sync(
bytes: &[u8],
dest_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
use std::io::Cursor;
let cursor = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|error| PluginError::InvalidManifest(format!("invalid zip archive: {error}")))?;
let mut total_decompressed_bytes: u64 = 0;
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| {
PluginError::InvalidManifest(format!("invalid zip entry at index {index}: {error}"))
})?;
let Some(relative_path) = file.enclosed_name() else {
return Err(PluginError::InvalidManifest(format!(
"zip entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
file.name()
)));
};
let out_path = dest_dir.join(&relative_path);
if file.is_dir() {
std::fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path)?;
if let Err(error) = copy_capped(
&mut file,
&mut out_file,
&mut total_decompressed_bytes,
max_decompressed_bytes,
) {
drop(out_file);
let _ = std::fs::remove_file(&out_path);
return Err(error);
}
drop(out_file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
}
Ok(())
}
fn extract_targz_sync(
bytes: &[u8],
dest_dir: &Path,
max_decompressed_bytes: u64,
) -> PluginResult<()> {
use flate2::read::GzDecoder;
use std::path::Component;
use tar::{Archive, EntryType};
let decoder = GzDecoder::new(bytes);
let mut archive = Archive::new(decoder);
let mut total_decompressed_bytes: u64 = 0;
for entry_result in archive.entries()? {
let mut entry = entry_result?;
let entry_type = entry.header().entry_type();
if matches!(entry_type, EntryType::Symlink | EntryType::Link) {
let link_target = entry
.link_name()
.ok()
.flatten()
.map(|path| path.display().to_string())
.unwrap_or_default();
return Err(PluginError::InvalidManifest(format!(
"tar entry '{}' is a {} (target '{link_target}') — plugin bundles must not ship \
links; refusing to unpack",
entry
.path()
.map(|p| p.display().to_string())
.unwrap_or_default(),
if entry_type == EntryType::Symlink {
"symlink"
} else {
"hardlink"
},
)));
}
let relative_path = entry.path()?.into_owned();
let is_unsafe = relative_path.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
if is_unsafe {
return Err(PluginError::InvalidManifest(format!(
"tar entry '{}' has an unsafe path (traversal/absolute) — refusing to unpack",
relative_path.display()
)));
}
let out_path = dest_dir.join(&relative_path);
if entry_type.is_dir() {
std::fs::create_dir_all(&out_path)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path)?;
if let Err(error) = copy_capped(
&mut entry,
&mut out_file,
&mut total_decompressed_bytes,
max_decompressed_bytes,
) {
drop(out_file);
let _ = std::fs::remove_file(&out_path);
return Err(error);
}
drop(out_file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(mode) = entry.header().mode() {
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
}
Ok(())
}
async fn read_and_parse_manifest(dir: &Path) -> PluginResult<PluginManifest> {
let manifest_path = dir.join("plugin.json");
let raw = tokio::fs::read_to_string(&manifest_path)
.await
.map_err(|_| {
PluginError::InvalidManifest(format!(
"no plugin.json found at '{}'",
manifest_path.display()
))
})?;
PluginManifest::parse_str(&raw)
}
async fn flatten_if_single_subdir(dir: &Path) -> PluginResult<()> {
if tokio::fs::try_exists(dir.join("plugin.json"))
.await
.unwrap_or(false)
{
return Ok(());
}
let mut entries = tokio::fs::read_dir(dir).await?;
let mut only_entry: Option<PathBuf> = None;
let mut count = 0usize;
while let Some(entry) = entries.next_entry().await? {
count += 1;
if count > 1 {
return Ok(());
}
only_entry = Some(entry.path());
}
let Some(candidate) = only_entry else {
return Ok(());
};
if !tokio::fs::symlink_metadata(&candidate).await?.is_dir() {
return Ok(());
}
let mut children = tokio::fs::read_dir(&candidate).await?;
while let Some(child) = children.next_entry().await? {
let dest = dir.join(child.file_name());
tokio::fs::rename(child.path(), dest).await?;
}
tokio::fs::remove_dir(&candidate).await?;
Ok(())
}
fn copy_dir_recursive<'a>(
source: &'a Path,
dest: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = PluginResult<()>> + Send + 'a>> {
Box::pin(async move {
tokio::fs::create_dir_all(dest).await?;
let mut entries = tokio::fs::read_dir(source).await?;
while let Some(entry) = entries.next_entry().await? {
let file_type = entry.file_type().await?;
let dest_path = dest.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path).await?;
} else if file_type.is_file() {
tokio::fs::copy(entry.path(), &dest_path).await?;
}
}
Ok(())
})
}
#[cfg(test)]
mod tests;